1697 lines
87 KiB
Python
1697 lines
87 KiB
Python
import os
|
||
import math
|
||
import numpy as np
|
||
import torch
|
||
from PIL import Image, ImageDraw, ImageFont
|
||
from datetime import datetime
|
||
|
||
# Global node registration dictionary
|
||
NODE_CLASS_MAPPINGS = {}
|
||
NODE_DISPLAY_NAME_MAPPINGS = {}
|
||
|
||
|
||
class ImageConcatNode:
|
||
"""✅A powerful image concatenation tool for ComfyUI, with True Alpha Channel Support and Multiple Image-title Fill Modes."""
|
||
|
||
@classmethod
|
||
def INPUT_TYPES(s):
|
||
return {
|
||
"required": {
|
||
# Basic Configuration
|
||
"a1_image_dir": ("STRING", {
|
||
"default": "",
|
||
"placeholder": "Path to image folder",
|
||
"tooltip": "Absolute path to the folder containing images to concatenate. Ignored if 'a0_images' are connected via input."
|
||
}),
|
||
"a2_page_width": ("INT", {
|
||
"default": 4000,
|
||
"min": 100,
|
||
"max": 50000,
|
||
"step": 10,
|
||
"tooltip": "Total width of the output canvas (px). Height is auto-calculated based on aspect ratio."
|
||
}),
|
||
"a3_page_aspect_ratio": ("COMBO", {
|
||
"default": "3:2",
|
||
"forceInput": False,
|
||
"options": ["10:1", "8:1", "5:1", "5:2", "16:9", "16:10", "3:2", "4:3", "1:1",
|
||
"3:4", "2:3", "10:16", "9:16", "2:5", "1:5", "1:8", "1:10"],
|
||
"tooltip": "Select the overall aspect ratio of the canvas."
|
||
}),
|
||
"a4_cols_rows_per_page": ("INT", {
|
||
"default": 3,
|
||
"min": 1,
|
||
"max": 20,
|
||
"step": 1,
|
||
"label": "a4_Cols/Rows per Page",
|
||
"description": "- Mode1-5: Number of columns per row\n- Mode6: Number of rows per page",
|
||
"tooltip": "Layout parameter: Columns per row for Mode 1-5, or Rows per page for Mode 6."
|
||
}),
|
||
"a5_page_margin": ("INT", {
|
||
"default": 50,
|
||
"min": 0,
|
||
"max": 500,
|
||
"step": 1,
|
||
"tooltip": "Margin space around the canvas border (px)."
|
||
}),
|
||
"a6_title_padding": ("INT", {
|
||
"default": 30,
|
||
"min": 0,
|
||
"max": 200,
|
||
"step": 1,
|
||
"tooltip": "Padding space between image title blocks (px)."
|
||
}),
|
||
"a7_title_draw_mode": ("COMBO", {
|
||
"default": "3.zoom by long side (recommended)",
|
||
"forceInput": False,
|
||
"options": [
|
||
"1.smaller value filler",
|
||
"2.Stretches image to fill",
|
||
"3.zoom by long side (recommended)",
|
||
"4.crop square by short side",
|
||
"5.equal title width up_down",
|
||
"6.equal title height left_right"
|
||
],
|
||
"tooltip": "Choose how images fit into their blocks (e.g., Scale, Stretch, Crop, Equal Width/Height)."
|
||
}),
|
||
"a8_title_first_position": ("COMBO", {
|
||
"default": "start_from margin",
|
||
"forceInput": False,
|
||
"options": [
|
||
"start_from margin",
|
||
"start_from margin + padding",
|
||
"start_from margin + padding(vertical centering)"
|
||
],
|
||
"tooltip": "Alignment for the first image block (e.g., Top-left alignment, Vertical Centering)."
|
||
}),
|
||
# Style Configuration
|
||
"a9_background_style": ("COMBO", {
|
||
"default": "Light (white)",
|
||
"forceInput": False,
|
||
"options": ["Light (white)", "Dark (black)", "Transparent (alpha channel)"],
|
||
"tooltip": "Set the canvas background style (White, Black, or Transparent)."
|
||
}),
|
||
"a10_title_border": ("COMBO", {
|
||
"default": "Rounded (radius=10px)",
|
||
"forceInput": False,
|
||
"options": ["None", "Rectangle", "Rounded (radius=10px)", "Rounded (radius=20px)"],
|
||
"tooltip": "Border shape for individual image blocks."
|
||
}),
|
||
"a11_title_border_style": ("COMBO", {
|
||
"default": "Solid",
|
||
"forceInput": False,
|
||
"options": ["Solid", "Dashed (4px,4px)", "Dashed (8px,8px)", "Dotted (1px,2px)",
|
||
"Dash-dot (8px,4ox,2px,4px)"],
|
||
"tooltip": "Line style for the image block borders (Solid, Dashed, Dotted, etc.)."
|
||
}),
|
||
"a12_page_border": ("COMBO", {
|
||
"default": "Rounded (radius=30px)",
|
||
"forceInput": False,
|
||
"options": ["None", "Rectangle", "Rounded (radius=10px)", "Rounded (radius=20px)",
|
||
"Rounded (radius=30px)"],
|
||
"tooltip": "Border shape for the whole page."
|
||
}),
|
||
"a13_page_border_style": ("COMBO", {
|
||
"default": "Solid",
|
||
"forceInput": False,
|
||
"options": ["Solid", "Dashed (4px,4px)", "Dashed (8px,8px)", "Dotted (1px,2px)",
|
||
"Dash-dot (8px,4ox,2px,4px)"],
|
||
"tooltip": "Line style for the page border."
|
||
}),
|
||
"a14_filename_position": ("COMBO", {
|
||
"default": "none",
|
||
"forceInput": False,
|
||
"options": ["none", "above", "top", "middle", "bottom", "below"],
|
||
"label": "a14_Filename Position",
|
||
"tooltip": "Position to display the filename (Above, Top, Middle, Bottom, Below, or None)."
|
||
}),
|
||
"a15_filename_color": ("COMBO", {
|
||
"default": "black",
|
||
"forceInput": False,
|
||
"options": [
|
||
"black",
|
||
"white",
|
||
"red",
|
||
"dark red",
|
||
"blue",
|
||
"dark blue",
|
||
"green",
|
||
"dark green",
|
||
"yellow",
|
||
"orange",
|
||
"purple",
|
||
"pink",
|
||
"light gray",
|
||
"dark gray",
|
||
"slate gray",
|
||
"cyan",
|
||
"magenta"
|
||
],
|
||
"label": "a15_Filename Color",
|
||
"tooltip": "Select the text color for the filename."
|
||
}),
|
||
# Save Configuration
|
||
"a97_title_save_mode": ("COMBO", {
|
||
"default": "none",
|
||
"forceInput": False,
|
||
"options": ["none", "save single title", "save single image"],
|
||
"label": "a97_Save Mode",
|
||
"tooltip": "Select whether to save individual blocks (with padding/title or original image only)."
|
||
}),
|
||
"a98_title_save_dir": (
|
||
"STRING", {
|
||
"default": "./output/concat_titles",
|
||
"placeholder": "title save directory path",
|
||
"tooltip": "Directory path to save the individual titles/images."
|
||
}),
|
||
"a99_title_save_filename": ("COMBO", {
|
||
"default": "source file name",
|
||
"forceInput": False,
|
||
"options": ["source file number", "source file name", "page + number"],
|
||
"label": "a99_Filename Convention",
|
||
"tooltip": "Select the naming rule for saved files (Index, Original Name, or Page+Index)."
|
||
}),
|
||
|
||
},
|
||
"optional": {
|
||
"a0_images": ("IMAGE",
|
||
{"tooltip": "select image(s) from other node.\n"
|
||
" If 'a0_images' are connected via input, `a1_image_dir` will be IGNORE."}),
|
||
},
|
||
}
|
||
|
||
RETURN_TYPES = ("IMAGE", "INT", "STRING", "INT", "STRING", "STRING")
|
||
RETURN_NAMES = (
|
||
"b1_concat_images", "b2_page_count", "b3_size_per_title", "b4_valid_image_count", "b5_title_save_path",
|
||
"b6_help_info")
|
||
FUNCTION = "generate_concat"
|
||
CATEGORY = "Image Processing/concat"
|
||
DESCRIPTION = "A powerful image concatenation tool for ComfyUI, with True Alpha Channel Support " \
|
||
"and Multiple Image-title Fill Modes. It can also be used as a image-resize tool."
|
||
|
||
def get_node_tips(self):
|
||
tips = """
|
||
===========================================================================
|
||
📌 【 Parameter Guide - Image Concat V1.1 Official Manual 】
|
||
【 参数说明 - Image Concat V1.1 官方手册 】
|
||
【Email/邮箱 2540968810@qq.com】
|
||
===========================================================================
|
||
|
||
【 I. Input Params A1~A17 Detailed Usage | 输入参数 A1 ~ A17 详细用法 】
|
||
---------------------------------------------------------------------------
|
||
▷ a0_images | 选择其他节点输入的图像 (可选) | select image(s) from other node (Optional)
|
||
| 如果连接了此节点,则`a1_image_dir`失效 | If 'a0_images' are connected via input, `a1_image_dir` will be IGNORE
|
||
▷ a1_image_dir | 图片文件夹绝对路径,必填 | Absolute path of image folder (Required)
|
||
| Supports upstream Image input (Optional). If connected, folder path is ignored.
|
||
▷ a2_page_width | 拼接画布总宽度(px),高度由宽高比自动计算 | Total width of canvas(px), height auto-calculated by aspect ratio
|
||
▷ a3_page_aspect_ratio | 画布整体宽高比 | Overall aspect ratio of canvas
|
||
| Options: 10:1 8:1 5:1 5:2 16:9 16:10 3:2 4:3 1:1 3:4 2:3 10:16 9:16 2:5 1:5 1:8 1:10
|
||
▷ a4_cols_rows_per_page | 全局通用队列数 | Global common queue count
|
||
| - Mode 1-4: 每行图片个数 (Columns per row)
|
||
| - Mode 5: 每列的块组数 (Groups per column)
|
||
| - Mode 6: 每页行数 (Rows per page)
|
||
▷ a5_page_margin | 画布四边留白边距(px) | Margin of canvas border(px) | Default=50
|
||
▷ a6_title_padding | 图片块之间的间距(px) | padding between image titles(px) | Default=30
|
||
▷ a7_title_draw_mode | 图片填充模式 | Image fill mode [Most Important]
|
||
| 1. smaller value filler: 等比缩放适配,留白填充 | Scale to fit, fill blank space
|
||
| 2. Stretches image to fill: 拉伸填满块 | Stretch to fill title (ignore ratio)
|
||
| 3. zoom by long side(recommend): 优先适配长边,等比缩放【推荐】| Fit long side first (Recommended)
|
||
| 4. crop square by short side: 裁剪短边为正方形后缩放 | Crop short side to square then scale
|
||
| 5. equal title width up_down: 等宽模式,纵向直连 | Equal-width mode, vertical connection
|
||
| 6. equal title height left_right: 等高模式,横向直连 | Equal-height mode, horizontal connection
|
||
▷ a8_title_first_position | 图片块起始绘制位置 | Image title start position
|
||
| ① start_from margin: 从边距处开始绘制 | Start at margin (Default)
|
||
| ② start_from margin + padding: 边距+间距处开始 | Start at margin+padding
|
||
| ③ start_from margin + padding(vertical centering): 垂直居中绘制 | Vertical centering
|
||
▷ a9_background_style | 画布背景样式 | Canvas background style
|
||
| Options: Light (white)/Dark (black)/Transparent (alpha channel)
|
||
▷ a10_title_border | 单个图片块的边框样式 | Single image title border style
|
||
| Options: None/Rectangle/Rounded (radius=10px)/Rounded (radius=20px)
|
||
▷ a11_title_border_style | 块边框线型 | title border line style
|
||
| Options: Solid/Dashed (4px,4px)/Dashed (8px,8px)/Dotted (1px,2px)/Dash-dot (8px,4ox,2px,4px)
|
||
▷ a12_page_border | 整页画布的外边框样式 | Whole page border style
|
||
| Options: None/Rectangle/Rounded (radius 10/20/30)
|
||
▷ a13_page_border_style | 画布外边框线型 | Page border line style | Same as title border options
|
||
▷ a14_filename_position | 文件名显示位置 | Filename display position
|
||
| none: 不显示 | above: 图块上方 | top: 图片内顶部
|
||
| middle: 图片内中部 | bottom: 图片内底部 | below: 图块下方
|
||
▷ a15_filename_color | 文件名显示颜色 | Filename display color
|
||
| Options: black/white/red/dark red/blue/dark blue/green/dark green
|
||
| /yellow/orange/purple/pink/light gray/dark gray/slate gray/cyan/magenta
|
||
▷ a97_title_save_mode | 独立块保存模式 | Save individual title mode
|
||
| none: 不保存 | save single title: 保存为title(含留白) | save single image: 保存为原始图(无留白)
|
||
▷ a98_title_save_dir | 独立块保存路径 | Save path of individual titles | Default=./output/concat_titles
|
||
▷ a99_title_save_filename | 独立块文件命名模式 | Save filename mode
|
||
| source file number: 按序号命名 (00001.jpg...)
|
||
| source file name: 使用原文件名 (默认)
|
||
| page + number: 页码+序号 (p1_1.png...),序号从1开始
|
||
|
||
【 II. Output Params B1~B6 Detailed Meaning | 输出参数 B1 ~ B6 详细含义 】
|
||
---------------------------------------------------------------------------
|
||
▷ b1_concat_images | 拼接完成的最终图片张量 | Final concatenated image tensor
|
||
| Can connect to Save Image node directly, multi-page as batch tensor
|
||
▷ b2_page_count | 本次拼接生成的总页数(整数) | Total pages of concatenation (Integer) | For counting/renaming
|
||
▷ b3_size_per_title | 单图片块的基准尺寸(字符串) | Base size of single image title (String) | e.g. 300×300
|
||
▷ b4_valid_image_count | 读取到的有效图片总数 | Total valid images read (Integer) | For verification
|
||
▷ b5_title_save_path | 独立块的最终保存路径 | Final save path of individual titles (String) | With timestamp
|
||
▷ b6_help_info | 本帮助手册 | This help manual | Real-time parameter reference
|
||
|
||
【 III. Core Features & Optimization Log | 核心特性与更新日志 】
|
||
---------------------------------------------------------------------------
|
||
✅ 1. Input Stream Support | 输入流支持
|
||
• Now supports connecting IMAGE output from other nodes directly.
|
||
现在支持直接连接其他节点的 IMAGE 输出。
|
||
• Automatically generates virtual filenames for display and saving logic.
|
||
自动生成虚拟文件名用于显示和保存逻辑。
|
||
|
||
✅ 2. Flexible Fill Modes (A7): | 灵活填充模式 (A7):
|
||
• Mode 1-4 (Grid): "Smaller value", "Stretch", "Zoom Long side (Best)", "Crop Square".
|
||
Provides full control over how images fit into their grid titles.
|
||
(1-4网格模式:提供4种方式控制图片来适配田字格)
|
||
• Mode 5 (Equal Width): Keeps columns same width. Images stack vertically. Good for long strips.
|
||
(5等宽模式:保持列宽一致,图片纵向堆叠,适合长图拼接)
|
||
• Mode 6 (Equal Height): Keeps rows same height. Images stack horizontally. Good for panoramas.
|
||
(6等高模式:保持行高一致,图片横向堆叠,适合全景图)
|
||
|
||
✅ 3. Smart Alignment (A8): | 智能对齐 (A8):
|
||
• "Vertical Centering": Automatically centers content vertically on the canvas when pages are not full.
|
||
(垂直居中:当页面未填满时,自动在画布垂直方向居中内容)
|
||
• "Last Row Centering": In multi-column modes (A4>1), if the last row is incomplete, it is centered.
|
||
(末行居中:多列模式下,若最后一行未满,自动居中显示)
|
||
|
||
✅ 4. Customizable Text & Style (A14, A15): | 自定义文本与样式 (A14, A15):
|
||
• Position: Show filenames (out of title: above/below, inside of title: top/middle/bottom).
|
||
(位置:显示文件名,位于图块或图片不同位置。)
|
||
• Color: Choose from 16 distinct colors (Black, White, Red, Blue, etc.) to suit any background.
|
||
(颜色:提供16种颜色选择,适配各种背景风格)
|
||
|
||
✅ 5. Rich Borders & Backgrounds (A9-A12, A10-A11): | 丰富边框与背景 (A9, A10-A11, A12-A13):
|
||
• Solid or Dashed borders with custom radius.
|
||
• Light, Dark, or Transparent backgrounds.
|
||
(实线/虚线边框及圆角设置。支持白色/黑色/透明背景)
|
||
|
||
✅ 6. New Save Mode (A98, A99): | 新增保存模式 (A97-A99):
|
||
• "Save single image": Saves the image without whitespace/padding (e.g., 1024x768).
|
||
(新增:保存原始图,去除title边距留白,例如1024x768)
|
||
• "Save single title": Saves the image within the title canvas (e.g., 1024x1024).
|
||
(保存title模式,包含边距留白,例如1024x1024)
|
||
• "3 title name method": Saves the image file by using 3 name method including source file number/source file name/ page + number.
|
||
(使用 3 种命名方法保存图像文件,包括源文件编号 / 源文件名称 / 页号 + 页内编号)
|
||
|
||
"""
|
||
return tips
|
||
|
||
def convert_ratio_to_float(self, ratio_str):
|
||
try:
|
||
width_part, height_part = ratio_str.split(":")
|
||
width_num = float(width_part)
|
||
height_num = float(height_part)
|
||
return width_num / height_num
|
||
except (ValueError, IndexError) as e:
|
||
print(f"[Warning] Invalid ratio format: {ratio_str}, using default 1.5 (3:2)")
|
||
return 1.5
|
||
|
||
def get_filename_color_by_name(self, color_name):
|
||
"""根据用户选择的颜色名称返回 RGB 值"""
|
||
color_map = {
|
||
"black": (0, 0, 0),
|
||
"white": (255, 255, 255),
|
||
"red": (255, 0, 0),
|
||
"dark red": (139, 0, 0),
|
||
"blue": (0, 0, 255),
|
||
"dark blue": (0, 0, 139),
|
||
"green": (0, 255, 0),
|
||
"dark green": (0, 100, 0),
|
||
"yellow": (255, 255, 0),
|
||
"orange": (255, 165, 0),
|
||
"purple": (128, 0, 128),
|
||
"pink": (255, 192, 203),
|
||
"light gray": (220, 220, 220),
|
||
"dark gray": (40, 40, 40),
|
||
"slate gray": (112, 128, 144),
|
||
"cyan": (0, 255, 255),
|
||
"magenta": (255, 0, 255)
|
||
}
|
||
return color_map.get(color_name, (0, 0, 0)) # 默认返回黑色
|
||
|
||
def get_dash_pattern(self, style_name):
|
||
if style_name == "Solid":
|
||
dash_pattern = None
|
||
elif style_name == "Dashed (4px,4px)":
|
||
dash_pattern = (4, 4)
|
||
elif style_name == "Dashed (8px,8px)":
|
||
dash_pattern = (8, 8)
|
||
elif style_name == "Dotted (1px,2px)":
|
||
dash_pattern = (1, 2)
|
||
elif style_name == "Dash-dot (8px,4ox,2px,4px)":
|
||
dash_pattern = (8, 4, 2, 4)
|
||
else:
|
||
dash_pattern = None
|
||
return dash_pattern
|
||
|
||
def draw_dashed_line_manual(self, draw, start, end, dash_pattern, width=2, color='black'):
|
||
if dash_pattern is None or len(dash_pattern) < 2:
|
||
draw.line([start, end], fill=color, width=width)
|
||
return
|
||
|
||
x1, y1 = start
|
||
x2, y2 = end
|
||
dx = x2 - x1
|
||
dy = y2 - y1
|
||
length = math.hypot(dx, dy)
|
||
if length == 0:
|
||
return
|
||
|
||
ux = dx / length
|
||
uy = dy / length
|
||
dash_on, dash_off = dash_pattern[:2] if len(dash_pattern) >= 2 else (1, 1)
|
||
current_pos = 0
|
||
draw_on = True
|
||
|
||
while current_pos < length:
|
||
if draw_on:
|
||
segment_length = min(dash_on, length - current_pos)
|
||
seg_end_x = x1 + ux * (current_pos + segment_length)
|
||
seg_end_y = y1 + uy * (current_pos + segment_length)
|
||
draw.line([(x1 + ux * current_pos, y1 + uy * current_pos), (seg_end_x, seg_end_y)], fill=color,
|
||
width=width)
|
||
current_pos += segment_length
|
||
else:
|
||
current_pos += dash_off
|
||
draw_on = not draw_on
|
||
|
||
def draw_dashed_rectangle_manual(self, draw, rect, dash_pattern, width=2, color='black'):
|
||
x1, y1, x2, y2 = rect
|
||
self.draw_dashed_line_manual(draw, (x1, y1), (x2, y1), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x2, y1), (x2, y2), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x2, y2), (x1, y2), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x1, y2), (x1, y1), dash_pattern, width, color)
|
||
|
||
def draw_dashed_rounded_rectangle_manual(self, draw, rect, radius, dash_pattern, width=2, color='black'):
|
||
x1, y1, x2, y2 = rect
|
||
r = min(radius, (x2 - x1) // 2, (y2 - y1) // 2)
|
||
draw.arc([x1, y1, x1 + 2 * r, y1 + 2 * r], 180, 270, fill=color, width=width)
|
||
draw.arc([x2 - 2 * r, y1, x2, y1 + 2 * r], 270, 0, fill=color, width=width)
|
||
draw.arc([x2 - 2 * r, y2 - 2 * r, x2, y2], 0, 90, fill=color, width=width)
|
||
draw.arc([x1, y2 - 2 * r, x1 + 2 * r, y2], 90, 180, fill=color, width=width)
|
||
self.draw_dashed_line_manual(draw, (x1 + r, y1), (x2 - r, y1), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x2, y1 + r), (x2, y2 - r), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x2 - r, y2), (x1 + r, y2), dash_pattern, width, color)
|
||
self.draw_dashed_line_manual(draw, (x1, y2 - r), (x1, y1 + r), dash_pattern, width, color)
|
||
|
||
def crop_center_square(self, img):
|
||
width, height = img.size
|
||
square_size = min(width, height)
|
||
left = (width - square_size) / 2
|
||
top = (height - square_size) / 2
|
||
right = (width + square_size) / 2
|
||
bottom = (height + square_size) / 2
|
||
return img.crop((left, top, right, bottom))
|
||
|
||
def get_background_config(self, background_style):
|
||
if background_style == "Light (white)":
|
||
return (255, 255, 255), 'RGB'
|
||
elif background_style == "Dark (black)":
|
||
return (0, 0, 0), 'RGB'
|
||
elif background_style == "Transparent (alpha channel)":
|
||
return (0, 0, 0, 0), 'RGBA'
|
||
else:
|
||
return (255, 255, 255), 'RGB'
|
||
|
||
def get_border_color(self, background_style):
|
||
if background_style == "Dark (black)":
|
||
return 'white'
|
||
else:
|
||
return 'black'
|
||
|
||
def get_font(self, font_size):
|
||
try:
|
||
if os.name == 'nt':
|
||
font = ImageFont.truetype("simhei.ttf", font_size)
|
||
else:
|
||
font = ImageFont.truetype("/System/Library/Fonts/Helvetica.ttc", font_size)
|
||
except:
|
||
font = ImageFont.load_default(size=font_size)
|
||
return font
|
||
|
||
def load_image_any_source(self, filename):
|
||
"""智能加载图像:优先从缓存(输入图像)加载,否则从磁盘加载"""
|
||
if self.use_input_images:
|
||
return self.image_cache.get(filename)
|
||
else:
|
||
return Image.open(os.path.join(self.image_dir_full, filename))
|
||
|
||
def save_single_title(self, img_resized, title_border, title_border_style,
|
||
save_dir, filename, add_filename, filename_color, save_mode, save_filename_mode,
|
||
page_num, idx, global_idx, w_title, h_title, border_width=2,
|
||
background_style="Light (white)"):
|
||
bg_color, img_mode = self.get_background_config(background_style)
|
||
border_color = self.get_border_color(background_style)
|
||
|
||
draw_name = os.path.splitext(filename)[0]
|
||
|
||
effective_add_filename = add_filename
|
||
if effective_add_filename == "above":
|
||
effective_add_filename = "top"
|
||
elif effective_add_filename == "below":
|
||
effective_add_filename = "bottom"
|
||
|
||
if save_filename_mode == "source file number":
|
||
_, ext = os.path.splitext(filename)
|
||
save_name = f"{global_idx + 1:05d}{ext}"
|
||
elif save_filename_mode == "page + number":
|
||
save_name = f"p{page_num}_{idx + 1}.png"
|
||
else:
|
||
save_name = filename
|
||
|
||
save_path = os.path.join(save_dir, save_name)
|
||
|
||
if save_mode == "image":
|
||
canvas_w = img_resized.width
|
||
canvas_h = img_resized.height
|
||
title_canvas = Image.new(img_mode, (canvas_w, canvas_h), color=bg_color)
|
||
title_canvas.paste(img_resized, (0, 0))
|
||
|
||
if effective_add_filename != "none" and filename:
|
||
draw = ImageDraw.Draw(title_canvas)
|
||
font_size = int(min(canvas_w, canvas_h) * 0.05)
|
||
font = self.get_font(max(font_size, 10))
|
||
text_bbox = draw.textbbox((0, 0), draw_name, font=font)
|
||
text_w = text_bbox[2] - text_bbox[0]
|
||
text_h = text_bbox[3] - text_bbox[1]
|
||
|
||
text_x = (canvas_w - text_w) // 2
|
||
gap = 8
|
||
|
||
if effective_add_filename == "top":
|
||
text_y = gap
|
||
elif effective_add_filename == "middle":
|
||
text_y = (canvas_h - text_h) // 2
|
||
elif effective_add_filename == "bottom":
|
||
text_y = canvas_h - text_h - gap
|
||
else:
|
||
text_y = 0
|
||
|
||
draw.text((text_x, text_y), draw_name, fill=filename_color, font=font)
|
||
|
||
if title_border != "None":
|
||
draw = ImageDraw.Draw(title_canvas)
|
||
dash_pattern = self.get_dash_pattern(title_border_style)
|
||
border_rect = [0, 0, canvas_w, canvas_h]
|
||
|
||
if "Rounded" in title_border:
|
||
radius = 10 if "10" in title_border else 20
|
||
self.draw_dashed_rounded_rectangle_manual(
|
||
draw, border_rect, radius, dash_pattern,
|
||
border_width, color=border_color
|
||
)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(
|
||
draw, border_rect, dash_pattern,
|
||
border_width, color=border_color
|
||
)
|
||
title_canvas.save(save_path, 'PNG', quality=100, pnginfo=None, optimize=False)
|
||
return
|
||
|
||
title_canvas = Image.new(img_mode, (int(w_title), int(h_title)), color=bg_color)
|
||
img_x = (int(w_title) - img_resized.width) // 2
|
||
img_y = (int(h_title) - img_resized.height) // 2
|
||
|
||
if img_resized.mode == 'RGB' and img_mode == 'RGBA':
|
||
img_resized = img_resized.convert('RGBA')
|
||
img_resized.putalpha(Image.new('L', img_resized.size, 255))
|
||
|
||
mask = img_resized.split()[-1] if img_mode == 'RGBA' else None
|
||
title_canvas.paste(img_resized, (img_x, img_y), mask=mask)
|
||
|
||
if effective_add_filename != "none" and filename:
|
||
draw = ImageDraw.Draw(title_canvas)
|
||
font_size = int(min(w_title, h_title) * 0.05)
|
||
font = self.get_font(font_size)
|
||
text_bbox = draw.textbbox((0, 0), draw_name, font=font)
|
||
text_w = text_bbox[2] - text_bbox[0]
|
||
text_h = text_bbox[3] - text_bbox[1]
|
||
|
||
text_x = (int(w_title) - text_w) // 2
|
||
gap = 8
|
||
|
||
if effective_add_filename == "top":
|
||
text_y = gap
|
||
elif effective_add_filename == "middle":
|
||
text_y = (int(h_title) - text_h) // 2
|
||
elif effective_add_filename == "bottom":
|
||
text_y = int(h_title) - text_h - gap
|
||
else:
|
||
text_y = 0
|
||
|
||
draw.text((text_x, text_y), draw_name, fill=filename_color, font=font)
|
||
|
||
if title_border != "None":
|
||
draw = ImageDraw.Draw(title_canvas)
|
||
dash_pattern = self.get_dash_pattern(title_border_style)
|
||
border_rect = [0, 0, int(w_title), int(h_title)]
|
||
|
||
if "Rounded" in title_border:
|
||
radius = 10 if "10" in title_border else 20
|
||
self.draw_dashed_rounded_rectangle_manual(
|
||
draw, border_rect, radius, dash_pattern,
|
||
border_width, color=border_color
|
||
)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(
|
||
draw, border_rect, dash_pattern,
|
||
border_width, color=border_color
|
||
)
|
||
title_canvas.save(save_path, 'PNG', quality=100, pnginfo=None, optimize=False)
|
||
|
||
def calc_vertical_title_groups_a4_1(self, image_files, width_page_use, height_page_use, padding, a7_mode, a8_mode):
|
||
if not image_files:
|
||
return []
|
||
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(image_files[0])
|
||
first_w, first_h = img.size
|
||
except:
|
||
first_w, first_h = 100, 100
|
||
# ------------------------
|
||
|
||
if first_w <= 0 or first_h <= 0:
|
||
first_w, first_h = 100, 100
|
||
|
||
global_meta = {'type': '', 'size': 0, 'width': 0, 'height': 0, 'layout': 'vertical'}
|
||
has_outer_padding = (a7_mode != "start_from margin")
|
||
|
||
if a8_mode in ["1.smaller value filler", "2.Stretches image to fill",
|
||
"3.zoom by long side (recommended)", "4.crop square by short side"]:
|
||
limit_w = width_page_use - (2 * padding if has_outer_padding else 0)
|
||
limit_w = max(limit_w, 10)
|
||
title_size = min(first_w, limit_w)
|
||
global_meta = {'type': 'square', 'size': int(title_size), 'layout': 'vertical'}
|
||
|
||
elif a8_mode == "5.equal title width up_down":
|
||
base_w = min(width_page_use, first_w)
|
||
h_limit = height_page_use - (2 * padding if has_outer_padding else 0)
|
||
while base_w > 0:
|
||
calc_h = int(base_w * (first_h / first_w)) if first_w > 0 else 100
|
||
if calc_h <= h_limit:
|
||
break
|
||
base_w -= 10
|
||
global_meta = {'type': 'fixed_width', 'width': int(base_w), 'layout': 'vertical'}
|
||
|
||
elif a8_mode == "6.equal title height left_right":
|
||
base_h = min(height_page_use, first_h)
|
||
w_limit = width_page_use - (2 * padding if has_outer_padding else 0)
|
||
while base_h > 0:
|
||
calc_w = int(base_h * (first_w / first_h)) if first_h > 0 else 100
|
||
if calc_w <= w_limit:
|
||
break
|
||
base_h -= 10
|
||
global_meta = {'type': 'fixed_height', 'height': int(base_h), 'layout': 'horizontal'}
|
||
|
||
pages = []
|
||
idx = 0
|
||
|
||
while idx < len(image_files):
|
||
current_page_images = []
|
||
|
||
is_horizontal = (global_meta['layout'] == 'horizontal')
|
||
|
||
if is_horizontal:
|
||
cursor = 0
|
||
if a7_mode != "start_from margin":
|
||
cursor += padding
|
||
limit = width_page_use
|
||
else:
|
||
cursor = 0
|
||
if a7_mode != "start_from margin":
|
||
cursor += padding
|
||
limit = height_page_use
|
||
|
||
while idx < len(image_files):
|
||
img_file = image_files[idx]
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file)
|
||
cur_w, cur_h = img.size
|
||
except:
|
||
cur_w, cur_h = 100, 100
|
||
# ------------------------
|
||
|
||
if cur_w <= 0 or cur_h <= 0:
|
||
cur_w, cur_h = 100, 100
|
||
|
||
dim = 0
|
||
if global_meta['type'] == 'square':
|
||
dim = global_meta['size']
|
||
elif global_meta['type'] == 'fixed_width':
|
||
bw = global_meta['width']
|
||
ratio = cur_h / cur_w if cur_w > 0 else 1
|
||
dim = int(bw * ratio)
|
||
elif global_meta['type'] == 'fixed_height':
|
||
bh = global_meta['height']
|
||
ratio = cur_w / cur_h if cur_h > 0 else 1
|
||
dim = int(bh * ratio)
|
||
|
||
dim = max(dim, 1)
|
||
|
||
if len(current_page_images) == 0:
|
||
if dim > limit:
|
||
dim = limit
|
||
else:
|
||
if cursor + padding + dim > limit:
|
||
break
|
||
|
||
current_page_images.append(img_file)
|
||
|
||
if len(current_page_images) == 0:
|
||
cursor += dim
|
||
else:
|
||
cursor += padding + dim
|
||
|
||
idx += 1
|
||
|
||
pages.append({'files': current_page_images, 'meta': global_meta})
|
||
|
||
return pages
|
||
|
||
def calc_unified_base_width_n1(self, img_org_w, img_org_h, width_page_use, height_page_use, title_first_position,
|
||
padding, theory_w_title):
|
||
if title_first_position == "start_from margin":
|
||
limit_width = width_page_use
|
||
limit_height = height_page_use
|
||
else:
|
||
limit_width = width_page_use - 2 * padding
|
||
limit_height = height_page_use - 2 * padding
|
||
|
||
img_ratio = img_org_w / img_org_h
|
||
init_base_w = min(theory_w_title, limit_width, img_org_w)
|
||
init_base_h = int(init_base_w / img_ratio)
|
||
|
||
unified_base_w = init_base_w
|
||
if init_base_h > limit_height:
|
||
unified_base_w = int(limit_height * img_ratio)
|
||
unified_base_w = min(unified_base_w, limit_width)
|
||
print(
|
||
f"[✅n=1反推生效] 首图高度超限 {init_base_h} > {limit_height} → 本页统一基准宽锁定: {unified_base_w}px")
|
||
else:
|
||
print(f"[✅n=1合规生效] 首图尺寸合规 → 本页统一基准宽锁定: {unified_base_w}px")
|
||
|
||
return unified_base_w
|
||
|
||
def calc_vertical_title_groups(self, image_files, height_page_use, padding, title_first_position, w_title_size_int,
|
||
n_per_row):
|
||
h_diff_title_size = []
|
||
img_wh_list = []
|
||
for img_file in image_files:
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file).convert('RGB')
|
||
img_wh_list.append((img.width, img.height))
|
||
except Exception as e:
|
||
print(f"[Error] Read img {img_file} failed: {e}")
|
||
img_wh_list.append((w_title_size_int, w_title_size_int))
|
||
# ------------------------
|
||
|
||
title_groups = []
|
||
h_title_group_size = []
|
||
current_group = []
|
||
current_col_height = 0
|
||
page_height_limit = height_page_use
|
||
if title_first_position != "start_from margin":
|
||
page_height_limit = height_page_use - 2 * padding
|
||
current_page_lock_w = w_title_size_int
|
||
|
||
for idx, (img_w, img_h) in enumerate(img_wh_list):
|
||
img_ratio = img_w / img_h
|
||
new_img_h = 0
|
||
|
||
if n_per_row == 1:
|
||
if len(current_group) == 0:
|
||
current_page_lock_w = self.calc_unified_base_width_n1(
|
||
img_w, img_h, self.width_page_use_global, height_page_use,
|
||
title_first_position, padding, w_title_size_int
|
||
)
|
||
new_img_h = int(current_page_lock_w / img_ratio)
|
||
|
||
else:
|
||
scale_ratio = w_title_size_int / img_w
|
||
new_img_h = int(img_h * scale_ratio)
|
||
|
||
h_diff_title_size.append(new_img_h)
|
||
|
||
temp_bottom_y = new_img_h if len(current_group) == 0 else current_col_height + padding + new_img_h
|
||
if temp_bottom_y > page_height_limit and len(current_group) > 0:
|
||
title_groups.append(current_group)
|
||
n = len(current_group)
|
||
group_h = sum(h_diff_title_size[i] for i in current_group) + (n - 1) * padding
|
||
h_title_group_size.append(group_h)
|
||
current_group = [idx]
|
||
current_col_height = new_img_h
|
||
else:
|
||
current_group.append(idx)
|
||
current_col_height = temp_bottom_y
|
||
|
||
if len(current_group) > 0:
|
||
title_groups.append(current_group)
|
||
n = len(current_group)
|
||
group_h = sum(h_diff_title_size[i] for i in current_group) + (n - 1) * padding
|
||
h_title_group_size.append(group_h)
|
||
|
||
print(f"[✅等宽模式] 生成 {len(title_groups)} 个纵向块组 | 组高度(精准公式): {h_title_group_size}")
|
||
return h_diff_title_size, h_title_group_size, title_groups, current_page_lock_w if n_per_row == 1 else w_title_size_int
|
||
|
||
def calc_h_each_row(self, height_page_use, padding, title_first_position, n_per_queue):
|
||
h_each_row_guess = 0
|
||
if title_first_position == "start_from margin":
|
||
h_each_row_guess = int((height_page_use - (n_per_queue - 1) * padding) / n_per_queue)
|
||
else:
|
||
h_each_row_guess = int((height_page_use - (n_per_queue + 1) * padding) / n_per_queue)
|
||
h_each_row = max(h_each_row_guess, 20)
|
||
print(f"[✅等高模式行高计算完成] a7={title_first_position} | 最终行高 h_each_row = {h_each_row} px")
|
||
return h_each_row
|
||
|
||
def calc_horizontal_row_groups(self, image_files, width_page_use, height_page_use, padding, title_first_position,
|
||
h_title_size_int, n_per_row):
|
||
w_diff_title_size = []
|
||
img_wh_list = []
|
||
for img_file in image_files:
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file).convert('RGB')
|
||
img_wh_list.append((img.width, img.height))
|
||
except Exception as e:
|
||
print(f"[Error] Read img {img_file} failed: {e}")
|
||
img_wh_list.append((h_title_size_int, h_title_size_int))
|
||
# ------------------------
|
||
|
||
h_each_row = self.calc_h_each_row(height_page_use, padding, title_first_position, n_per_row)
|
||
|
||
for img_w, img_h in img_wh_list:
|
||
img_ratio = img_w / img_h
|
||
new_img_w = int(h_each_row * img_ratio)
|
||
w_diff_title_size.append(new_img_w)
|
||
|
||
row_groups = []
|
||
w_row_group_size = []
|
||
current_row = []
|
||
current_row_width = 0
|
||
|
||
for idx, img_w_calc in enumerate(w_diff_title_size):
|
||
n = len(current_row)
|
||
if n == 0:
|
||
current_row.append(idx)
|
||
current_row_width = img_w_calc
|
||
else:
|
||
if title_first_position == "start_from margin":
|
||
judge_width = current_row_width + img_w_calc + (n - 1) * padding
|
||
else:
|
||
judge_width = current_row_width + img_w_calc + (n + 1) * padding
|
||
|
||
if judge_width > width_page_use:
|
||
row_groups.append(current_row)
|
||
row_w = sum(w_diff_title_size[i] for i in current_row) + (len(current_row) - 1) * padding
|
||
w_row_group_size.append(row_w)
|
||
current_row = [idx]
|
||
current_row_width = img_w_calc
|
||
else:
|
||
current_row.append(idx)
|
||
current_row_width += img_w_calc
|
||
|
||
if len(current_row) > 0:
|
||
row_groups.append(current_row)
|
||
row_w = sum(w_diff_title_size[i] for i in current_row) + (len(current_row) - 1) * padding
|
||
w_row_group_size.append(row_w)
|
||
|
||
total_rows = len(row_groups)
|
||
rows_per_page = n_per_row
|
||
page_row_mapping = []
|
||
page_total_occupy_h = []
|
||
for i in range(0, total_rows, rows_per_page):
|
||
page_rows = row_groups[i:i + rows_per_page]
|
||
page_row_mapping.append(page_rows)
|
||
if title_first_position == "start_from margin":
|
||
page_h = len(page_rows) * h_each_row + (len(page_rows) - 1) * padding
|
||
else:
|
||
page_h = len(page_rows) * h_each_row + (len(page_rows) + 1) * padding
|
||
page_total_occupy_h.append(page_h)
|
||
|
||
print(
|
||
f"[✅等高模式] 生成 {len(row_groups)} 个横向行组 | 分页后总页数: {len(page_row_mapping)} | 行宽度列表: {w_row_group_size}")
|
||
return w_diff_title_size, w_row_group_size, row_groups, page_row_mapping, page_total_occupy_h, h_each_row
|
||
|
||
def create_single_concat_page(self, image_files_page, width_page, height_page, n_per_row, n_per_col_int,
|
||
margin, padding, title_first_position,
|
||
w_title_size, h_title_size, draw_mode, title_border, title_border_style,
|
||
page_border, page_border_style, page_num,
|
||
save_mode, titles_save_dir, save_filename_mode, global_start_idx,
|
||
background_style, vertical_offset_mode,
|
||
image_count_in_dir, current_page_group_count=0, page_total_occupy_h=0,
|
||
add_filename="none", page_meta=None, filename_color="black"):
|
||
width_page_int = int(round(width_page))
|
||
height_page_int = int(round(height_page))
|
||
w_title_size_int = int(round(w_title_size))
|
||
h_title_size_int = int(round(h_title_size))
|
||
|
||
is_a4_equals_1 = (page_meta is not None)
|
||
|
||
is_start_from_margin = (title_first_position == "start_from margin")
|
||
is_vert_center = (title_first_position == "start_from margin + padding(vertical centering)")
|
||
has_outer_padding = (not is_start_from_margin)
|
||
|
||
w_title_size_int = int(round(w_title_size)) if w_title_size > 0 else 100
|
||
h_title_size_int = int(round(h_title_size)) if h_title_size > 0 else 100
|
||
|
||
if is_a4_equals_1:
|
||
if page_meta['type'] == 'square':
|
||
w_title_size_int = page_meta['size']
|
||
h_title_size_int = page_meta['size']
|
||
elif page_meta['type'] == 'fixed_width':
|
||
w_title_size_int = page_meta['width']
|
||
h_title_size_int = -1
|
||
elif page_meta['type'] == 'fixed_height':
|
||
w_title_size_int = -1
|
||
h_title_size_int = page_meta['height']
|
||
else:
|
||
if draw_mode in ["1.smaller value filler", "2.Stretches image to fill",
|
||
"3.zoom by long side (recommended)", "4.crop square by short side"]:
|
||
w_title_size_int = h_title_size_int = int(w_title_size)
|
||
|
||
bg_color, img_mode = self.get_background_config(background_style)
|
||
border_color = self.get_border_color(background_style)
|
||
|
||
concat = Image.new(img_mode, (width_page_int, height_page_int), color=bg_color)
|
||
draw = ImageDraw.Draw(concat)
|
||
|
||
dash_title = self.get_dash_pattern(title_border_style)
|
||
dash_page = self.get_dash_pattern(page_border_style)
|
||
|
||
height_page_use = height_page - 2 * margin
|
||
width_page_use = width_page - 2 * margin
|
||
|
||
filename_draw_info = []
|
||
|
||
if is_a4_equals_1:
|
||
is_horizontal_layout = (page_meta.get('layout') == 'horizontal')
|
||
|
||
if is_horizontal_layout:
|
||
# Mode 6: Horizontal
|
||
dims = []
|
||
for img_file in image_files_page:
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file)
|
||
orig_w, orig_h = img.size
|
||
if orig_w <= 0 or orig_h <= 0: orig_w, orig_h = 100, 100
|
||
except:
|
||
orig_w, orig_h = 100, 100
|
||
# ------------------------
|
||
ratio = orig_w / orig_h if orig_h > 0 else 1
|
||
dw = int(h_title_size_int * ratio)
|
||
dims.append(dw)
|
||
|
||
if has_outer_padding:
|
||
total_w = sum(dims) + (len(dims) + 1) * padding
|
||
else:
|
||
total_w = sum(dims) + (len(dims) - 1) * padding
|
||
x_offset = int((width_page_use - total_w) / 2)
|
||
|
||
h_content = h_title_size_int
|
||
if has_outer_padding:
|
||
h_content += 2 * padding
|
||
y_offset = int((height_page_use - h_content) / 2)
|
||
|
||
cursor_x = margin + x_offset
|
||
cursor_y = margin + y_offset
|
||
|
||
for idx, img_file in enumerate(image_files_page):
|
||
dw = dims[idx]
|
||
current_global_idx = global_start_idx + idx
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file).convert('RGB')
|
||
orig_w, orig_h = img.size
|
||
if orig_w <= 0 or orig_h <= 0: orig_w, orig_h = 100, 100
|
||
|
||
dh = h_title_size_int
|
||
ratio = orig_w / orig_h if orig_h > 0 else 1
|
||
dw_calc = int(dh * ratio)
|
||
|
||
img_resized = img.resize((dw_calc, dh), Image.Resampling.LANCZOS)
|
||
|
||
title_x = cursor_x
|
||
title_y = cursor_y
|
||
if has_outer_padding:
|
||
title_x += padding
|
||
|
||
img_draw_x = title_x + (dw_calc - img_resized.width) // 2
|
||
img_draw_y = title_y
|
||
|
||
# Save Logic
|
||
if save_mode != "none":
|
||
self.save_single_title(img_resized, title_border, title_border_style,
|
||
titles_save_dir, img_file, add_filename, filename_color,
|
||
"title" if save_mode == "save single title" else "image",
|
||
save_filename_mode, page_num, idx, current_global_idx,
|
||
dw_calc, dh, background_style=background_style)
|
||
|
||
if img_resized.mode == 'RGB' and img_mode == 'RGBA':
|
||
img_resized = img_resized.convert('RGBA')
|
||
img_resized.putalpha(Image.new('L', img_resized.size, 255))
|
||
mask = img_resized.split()[-1] if img_mode == 'RGBA' else None
|
||
concat.paste(img_resized, (int(img_draw_x), int(img_draw_y)), mask=mask)
|
||
|
||
if title_border != "None":
|
||
rect = [int(title_x), int(title_y), int(title_x + dw_calc), int(title_y + dh)]
|
||
if "Rounded" in title_border:
|
||
self.draw_dashed_rounded_rectangle_manual(draw, rect, 10, dash_title, 2, border_color)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(draw, rect, dash_title, 2, border_color)
|
||
|
||
# Filename Queue
|
||
if add_filename != "none":
|
||
text_color = filename_color
|
||
|
||
font_size = int(min(dw_calc, dh) * 0.05)
|
||
font = self.get_font(max(font_size, 10))
|
||
text_bbox = draw.textbbox((0, 0), img_file, font=font)
|
||
text_w = text_bbox[2] - text_bbox[0]
|
||
text_h = text_bbox[3] - text_bbox[1]
|
||
text_x = title_x + (dw_calc - text_w) // 2
|
||
gap = 8
|
||
if add_filename == "above":
|
||
text_y = title_y - text_h - gap
|
||
elif add_filename == "top":
|
||
text_y = img_draw_y + gap
|
||
elif add_filename == "middle":
|
||
text_y = img_draw_y + (dh - text_h) // 2
|
||
elif add_filename == "bottom":
|
||
text_y = img_draw_y + dh - text_h - gap
|
||
elif add_filename == "below":
|
||
text_y = title_y + dh + gap
|
||
filename_draw_info.append({'xy': (text_x, text_y),
|
||
'rect': [text_x - 5, text_y - 2, text_x + text_w + 5,
|
||
text_y + text_h + 2], 'text': img_file, 'font': font,
|
||
'fill': text_color, 'bg': None})
|
||
|
||
cursor_x += dw_calc + padding
|
||
except Exception as e:
|
||
print(f"[Error] a4=1 mode6 draw {idx}: {e}")
|
||
else:
|
||
# Mode 1-5: Vertical
|
||
dims = []
|
||
for img_file in image_files_page:
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file)
|
||
orig_w, orig_h = img.size
|
||
if orig_w <= 0 or orig_h <= 0: orig_w, orig_h = 100, 100
|
||
except:
|
||
orig_w, orig_h = 100, 100
|
||
# ------------------------
|
||
|
||
if page_meta['type'] == 'square':
|
||
dh = w_title_size_int
|
||
elif page_meta['type'] == 'fixed_width':
|
||
dh = int(w_title_size_int * (orig_h / orig_w if orig_w > 0 else 1))
|
||
dims.append(max(dh, 1))
|
||
|
||
n = len(dims)
|
||
if is_vert_center:
|
||
if has_outer_padding:
|
||
total_h = (n + 1) * padding + sum(dims)
|
||
else:
|
||
total_h = (n - 1) * padding + sum(dims)
|
||
offset = int((height_page_use - total_h) / 2)
|
||
cursor_y = margin + offset
|
||
else:
|
||
if has_outer_padding:
|
||
cursor_y = margin + padding
|
||
else:
|
||
cursor_y = margin
|
||
|
||
content_w = w_title_size_int
|
||
x_offset = int((width_page_use - content_w) / 2)
|
||
cursor_x_base = margin + x_offset
|
||
|
||
for idx, img_file in enumerate(image_files_page):
|
||
dh = dims[idx]
|
||
dw = w_title_size_int
|
||
current_global_idx = global_start_idx + idx
|
||
|
||
title_x = cursor_x_base
|
||
title_y = cursor_y
|
||
|
||
if idx == 0 and has_outer_padding:
|
||
title_y += padding
|
||
elif idx > 0:
|
||
title_y += padding
|
||
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file).convert('RGB')
|
||
orig_w, orig_h = img.size
|
||
if orig_w <= 0 or orig_h <= 0: orig_w, orig_h = 100, 100
|
||
|
||
if page_meta['type'] == 'square':
|
||
if draw_mode == "2.Stretches image to fill":
|
||
img_resized = img.resize((dw, dh), Image.Resampling.LANCZOS)
|
||
elif draw_mode == "1.smaller value filler":
|
||
long_side = max(orig_w, orig_h)
|
||
target_side = min(long_side, dw)
|
||
scale = target_side / long_side
|
||
new_w = int(orig_w * scale);
|
||
new_h = int(orig_h * scale)
|
||
img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||
elif draw_mode == "3.zoom by long side (recommended)":
|
||
long_side = max(orig_w, orig_h)
|
||
scale = dw / long_side
|
||
new_w = int(orig_w * scale);
|
||
new_h = int(orig_h * scale)
|
||
img_resized = img.resize((new_w, new_h), Image.Resampling.LANCZOS)
|
||
elif draw_mode == "4.crop square by short side":
|
||
img_sq = self.crop_center_square(img)
|
||
img_resized = img_sq.resize((dw, dh), Image.Resampling.LANCZOS)
|
||
else:
|
||
img_resized = img.resize((dw, dh), Image.Resampling.LANCZOS)
|
||
|
||
img_draw_x = title_x
|
||
img_draw_y = title_y
|
||
if page_meta['type'] == 'square':
|
||
img_draw_x += (dw - img_resized.width) // 2
|
||
img_draw_y += (dh - img_resized.height) // 2
|
||
elif page_meta['type'] == 'fixed_width':
|
||
img_draw_y += (dh - img_resized.height) // 2
|
||
|
||
# Save Logic
|
||
if save_mode != "none":
|
||
self.save_single_title(img_resized, title_border, title_border_style,
|
||
titles_save_dir, img_file, add_filename, filename_color,
|
||
"title" if save_mode == "save single title" else "image",
|
||
save_filename_mode, page_num, idx, current_global_idx,
|
||
dw, dh, background_style=background_style)
|
||
|
||
if img_resized.mode == 'RGB' and img_mode == 'RGBA':
|
||
img_resized = img_resized.convert('RGBA')
|
||
img_resized.putalpha(Image.new('L', img_resized.size, 255))
|
||
mask = img_resized.split()[-1] if img_mode == 'RGBA' else None
|
||
concat.paste(img_resized, (int(img_draw_x), int(img_draw_y)), mask=mask)
|
||
|
||
if title_border != "None":
|
||
if page_meta['type'] == 'square':
|
||
rect = [int(title_x), int(title_y), int(title_x + dw), int(title_y + dh)]
|
||
else:
|
||
rect = [int(title_x), int(title_y), int(title_x + img_resized.width),
|
||
int(title_y + img_resized.height)]
|
||
if "Rounded" in title_border:
|
||
self.draw_dashed_rounded_rectangle_manual(draw, rect, 10, dash_title, 2, border_color)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(draw, rect, dash_title, 2, border_color)
|
||
|
||
# Filename Queue
|
||
if add_filename != "none":
|
||
text_color = filename_color
|
||
|
||
ref_h = dh if page_meta['type'] == 'square' else img_resized.height
|
||
ref_w = dw if page_meta['type'] == 'square' else img_resized.width
|
||
font_size = int(min(ref_w, ref_h) * 0.05)
|
||
font = self.get_font(max(font_size, 10))
|
||
text_bbox = draw.textbbox((0, 0), img_file, font=font)
|
||
text_w = text_bbox[2] - text_bbox[0]
|
||
text_h = text_bbox[3] - text_bbox[1]
|
||
text_x = title_x + (dw - text_w) // 2
|
||
gap = 8
|
||
if add_filename == "above":
|
||
text_y = title_y - text_h - gap
|
||
elif add_filename == "top":
|
||
text_y = img_draw_y + gap
|
||
elif add_filename == "middle":
|
||
text_y = img_draw_y + (ref_h - text_h) // 2
|
||
elif add_filename == "bottom":
|
||
text_y = img_draw_y + ref_h - text_h - gap
|
||
elif add_filename == "below":
|
||
text_y = title_y + ref_h + gap
|
||
filename_draw_info.append({'xy': (text_x, text_y),
|
||
'rect': [text_x - 5, text_y - 2, text_x + text_w + 5,
|
||
text_y + text_h + 2], 'text': img_file, 'font': font,
|
||
'fill': text_color, 'bg': None})
|
||
|
||
cursor_y = title_y + dh + padding
|
||
except Exception as e:
|
||
print(f"[Error] a4=1 draw {idx}: {e}")
|
||
else:
|
||
# ================= a4>1 (Multi-Column) Logic =================
|
||
equal_width_mode = draw_mode == "5.equal title width up_down"
|
||
equal_height_mode = draw_mode == "6.equal title height left_right"
|
||
h_diff_title_size = []
|
||
h_title_group_size = []
|
||
title_groups = []
|
||
page_lock_width = w_title_size_int
|
||
w_diff_title_size = []
|
||
w_row_group_size = []
|
||
row_groups = []
|
||
page_lock_height = h_title_size_int
|
||
page_total_occupy_h_local = []
|
||
|
||
# Vertical Centering Calc
|
||
y_offset = 0
|
||
if vertical_offset_mode:
|
||
actual_rows = (len(image_files_page) + n_per_row - 1) // n_per_row
|
||
if title_first_position == "start_from margin":
|
||
total_title_area_height = actual_rows * w_title_size_int + (actual_rows - 1) * padding
|
||
else:
|
||
total_title_area_height = actual_rows * w_title_size_int + (actual_rows + 1) * padding
|
||
y_offset = int((height_page_use - total_title_area_height) / 2)
|
||
y_offset = max(y_offset, 0)
|
||
|
||
# Horizontal Centering Calc
|
||
x_offset = 0
|
||
mod_int = image_count_in_dir % n_per_row
|
||
if image_count_in_dir < n_per_row:
|
||
x_offset = int(
|
||
0.5 * (width_page_int - (image_count_in_dir - 1) * padding - image_count_in_dir * w_title_size_int))
|
||
|
||
center_offset_x = 0
|
||
x_offset_last_row = 0
|
||
|
||
normal_mode = not equal_width_mode and not equal_height_mode
|
||
if normal_mode and vertical_offset_mode:
|
||
total_title_area_width = n_per_row * w_title_size_int + (n_per_row - 1) * padding
|
||
center_offset_x = int((width_page_use - total_title_area_width) / 2)
|
||
x_offset_last_row = center_offset_x
|
||
|
||
if equal_width_mode:
|
||
h_diff_title_size, h_title_group_size, title_groups, page_lock_width = self.calc_vertical_title_groups(
|
||
image_files_page, height_page_use, padding, title_first_position, w_title_size_int, n_per_row
|
||
)
|
||
if n_per_row == 1:
|
||
center_offset_x = int((width_page_use - page_lock_width) / 2)
|
||
elif current_page_group_count > 0 and current_page_group_count < n_per_row:
|
||
total_group_occupy_width = current_page_group_count * w_title_size_int + (
|
||
current_page_group_count - 1) * padding
|
||
center_offset_x = int((width_page_use - total_group_occupy_width) / 2)
|
||
|
||
elif equal_height_mode:
|
||
w_diff_title_size, w_row_group_size, row_groups, _, page_total_occupy_h_local, page_lock_height = self.calc_horizontal_row_groups(
|
||
image_files_page, width_page_use, height_page_use, padding, title_first_position, h_title_size_int,
|
||
n_per_row
|
||
)
|
||
if n_per_row == 1:
|
||
center_offset_x = int((width_page_use - page_lock_height) / 2)
|
||
|
||
mode6_center_y = 0
|
||
if vertical_offset_mode and len(page_total_occupy_h_local) > 0 and page_total_occupy_h_local[
|
||
0] < height_page_use:
|
||
total_h_current = page_total_occupy_h_local[0]
|
||
mode6_center_y = int((height_page_use - total_h_current) / 2)
|
||
|
||
# Drawing Loop
|
||
for idx, img_file in enumerate(image_files_page):
|
||
current_global_idx = global_start_idx + idx
|
||
# --- 修改:使用新加载器 ---
|
||
try:
|
||
img = self.load_image_any_source(img_file).convert('RGB')
|
||
img_org_w, img_org_h = img.size
|
||
# ...
|
||
# 保持原有逻辑
|
||
canvas_x = 0
|
||
canvas_y = 0
|
||
resize_w = w_title_size_int
|
||
resize_h = h_title_size_int
|
||
img_x = 0
|
||
img_y = 0
|
||
canvas_x_int = 0
|
||
canvas_y_int = 0
|
||
|
||
if equal_width_mode and len(title_groups) > 0:
|
||
group_idx = -1
|
||
inner_idx = -1
|
||
for g_idx, group in enumerate(title_groups):
|
||
if idx in group:
|
||
group_idx = g_idx
|
||
inner_idx = group.index(idx)
|
||
break
|
||
if group_idx >= 0:
|
||
add_offset = padding if title_first_position != "start_from margin" else 0
|
||
canvas_x = margin + group_idx * (w_title_size_int + padding) + center_offset_x + add_offset
|
||
group = title_groups[group_idx]
|
||
inner_y = sum(
|
||
h_diff_title_size[group[i]] + padding for i in range(inner_idx)) if inner_idx > 0 else 0
|
||
if vertical_offset_mode:
|
||
title_group_center_y = int(
|
||
margin + (height_page_use - h_title_group_size[group_idx]) / 2)
|
||
canvas_y = title_group_center_y + inner_y + add_offset
|
||
else:
|
||
canvas_y = margin + inner_y + y_offset + add_offset
|
||
|
||
current_h_title = h_diff_title_size[idx]
|
||
resize_w = page_lock_width if n_per_row == 1 else w_title_size_int
|
||
resize_h = current_h_title
|
||
img_ratio = img_org_w / img_org_h
|
||
resize_h = int(resize_w / img_ratio) if img_ratio != 0 else resize_w
|
||
img_resized = img.resize((resize_w, resize_h), Image.Resampling.LANCZOS)
|
||
img_x = int(canvas_x)
|
||
img_y = int(canvas_y)
|
||
canvas_x_int = img_x
|
||
canvas_y_int = img_y
|
||
|
||
elif equal_height_mode and len(row_groups) > 0:
|
||
row_idx = -1
|
||
inner_idx = -1
|
||
for r_idx, row in enumerate(row_groups):
|
||
if idx in row:
|
||
row_idx = r_idx
|
||
inner_idx = row.index(idx)
|
||
break
|
||
if row_idx >= 0:
|
||
add_offset = padding if title_first_position != "start_from margin" else 0
|
||
|
||
canvas_y = margin + mode6_center_y + row_idx * (page_lock_height + padding) + add_offset
|
||
inner_x = sum(
|
||
w_diff_title_size[row[i]] + padding for i in range(inner_idx)) if inner_idx > 0 else 0
|
||
row_center_x = int((width_page_use - w_row_group_size[row_idx]) / 2)
|
||
canvas_x = margin - add_offset + row_center_x + inner_x + center_offset_x + padding
|
||
|
||
current_w_title = w_diff_title_size[idx]
|
||
resize_w = current_w_title
|
||
resize_h = page_lock_height
|
||
img_ratio = img_org_w / img_org_h
|
||
resize_w = int(resize_h * img_ratio) if img_ratio != 0 else resize_h
|
||
img_resized = img.resize((resize_w, resize_h), Image.Resampling.LANCZOS)
|
||
img_x = int(canvas_x)
|
||
img_y = int(canvas_y)
|
||
canvas_x_int = img_x
|
||
canvas_y_int = img_y
|
||
|
||
else:
|
||
col = idx % n_per_row
|
||
row = idx // n_per_row
|
||
idx_total = idx + (page_num - 1) * n_per_row * n_per_col_int
|
||
if title_first_position == "start_from margin":
|
||
canvas_x = margin + col * (w_title_size_int + padding)
|
||
canvas_y = margin + row * (w_title_size_int + padding) + y_offset
|
||
elif title_first_position in ["start_from margin + padding",
|
||
"start_from margin + padding(vertical centering)"]:
|
||
canvas_x = margin + padding + col * (w_title_size_int + padding)
|
||
canvas_y = margin + padding + row * (w_title_size_int + padding) + y_offset
|
||
|
||
remaining_images = image_count_in_dir - idx_total
|
||
is_last_row = remaining_images > 0 and remaining_images <= n_per_row
|
||
is_incomplete_row = remaining_images > 0 and remaining_images < n_per_row
|
||
|
||
if col == 0 and is_last_row and is_incomplete_row:
|
||
total_row_width = remaining_images * w_title_size_int + (remaining_images - 1) * padding
|
||
unused_width = width_page_use - total_row_width
|
||
x_offset_last_row = unused_width // 2
|
||
|
||
canvas_x_int = int(canvas_x) + x_offset + x_offset_last_row
|
||
canvas_y_int = int(canvas_y)
|
||
resize_w = w_title_size_int
|
||
resize_h = w_title_size_int
|
||
|
||
if draw_mode == "2.Stretches image to fill":
|
||
img_resized = img.resize((resize_w, resize_h), Image.Resampling.LANCZOS)
|
||
img_x, img_y = canvas_x_int, canvas_y_int
|
||
elif draw_mode == "1.smaller value filler":
|
||
ls = max(img.width, img.height)
|
||
sb = min(ls, resize_w)
|
||
s = sb / ls
|
||
nw = int(img.width * s);
|
||
nh = int(img.height * s)
|
||
img_resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
|
||
img_x = canvas_x_int + (resize_w - nw) // 2
|
||
img_y = canvas_y_int + (resize_h - nh) // 2
|
||
elif draw_mode == "3.zoom by long side (recommended)":
|
||
ls = max(img.width, img.height)
|
||
s = resize_w / ls
|
||
nw = int(img.width * s);
|
||
nh = int(img.height * s)
|
||
img_resized = img.resize((nw, nh), Image.Resampling.LANCZOS)
|
||
img_x = canvas_x_int + (resize_w - nw) // 2
|
||
img_y = canvas_y_int + (resize_h - nh) // 2
|
||
elif draw_mode == "4.crop square by short side":
|
||
img_sq = self.crop_center_square(img)
|
||
img_resized = img_sq.resize((resize_w, resize_h), Image.Resampling.LANCZOS)
|
||
img_x, img_y = canvas_x_int, canvas_y_int
|
||
else:
|
||
img_resized = img.resize((resize_w, resize_h), Image.Resampling.LANCZOS)
|
||
img_x, img_y = canvas_x_int, canvas_y_int
|
||
|
||
# Paste
|
||
if save_mode != "none":
|
||
self.save_single_title(img_resized, title_border, title_border_style,
|
||
titles_save_dir, img_file, add_filename, filename_color,
|
||
"title" if save_mode == "save single title" else "image",
|
||
save_filename_mode, page_num, idx, current_global_idx,
|
||
resize_w, resize_h, background_style=background_style)
|
||
|
||
if img_resized.mode == 'RGB' and img_mode == 'RGBA':
|
||
img_resized = img_resized.convert('RGBA')
|
||
alpha_layer = Image.new('L', img_resized.size, 255)
|
||
img_resized.putalpha(alpha_layer)
|
||
|
||
mask = img_resized.split()[-1] if img_mode == 'RGBA' else None
|
||
img_x = max(0, min(img_x, width_page_int - img_resized.width))
|
||
concat.paste(img_resized, (img_x, img_y), mask=mask)
|
||
|
||
# Border
|
||
if title_border != "None":
|
||
ref_w = resize_w if not (equal_width_mode or equal_height_mode) else (
|
||
resize_w if equal_width_mode else w_diff_title_size[idx])
|
||
ref_h = resize_h if not (equal_width_mode or equal_height_mode) else (
|
||
resize_h if equal_width_mode else resize_h)
|
||
|
||
bx = canvas_x_int
|
||
by = canvas_y_int
|
||
if equal_height_mode:
|
||
bx = img_x
|
||
|
||
rect = [bx, by, bx + ref_w, by + ref_h]
|
||
if "Rounded" in title_border:
|
||
self.draw_dashed_rounded_rectangle_manual(draw, rect, 10, dash_title, 2, border_color)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(draw, rect, dash_title, 2, border_color)
|
||
|
||
# Filename Queue
|
||
if add_filename != "none":
|
||
text_color = filename_color
|
||
|
||
ref_w = resize_w
|
||
if equal_height_mode:
|
||
ref_w = img_resized.width
|
||
|
||
ref_h = resize_h
|
||
|
||
font_size = int(min(ref_w, ref_h) * 0.05)
|
||
font = self.get_font(max(font_size, 10))
|
||
text_bbox = draw.textbbox((0, 0), img_file, font=font)
|
||
text_w = text_bbox[2] - text_bbox[0]
|
||
text_h = text_bbox[3] - text_bbox[1]
|
||
|
||
text_x = canvas_x_int + (ref_w - text_w) // 2
|
||
|
||
gap = 8
|
||
|
||
if add_filename == "above":
|
||
text_y = canvas_y_int - text_h - gap
|
||
elif add_filename == "top":
|
||
text_y = canvas_y_int + gap
|
||
elif add_filename == "middle":
|
||
text_y = canvas_y_int + (resize_h - text_h) // 2
|
||
elif add_filename == "bottom":
|
||
text_y = canvas_y_int + resize_h - text_h - gap
|
||
elif add_filename == "below":
|
||
text_y = canvas_y_int + resize_h + gap
|
||
filename_draw_info.append({'xy': (text_x, text_y),
|
||
'rect': [text_x - 5, text_y - 2, text_x + text_w + 5,
|
||
text_y + text_h + 2], 'text': img_file, 'font': font,
|
||
'fill': text_color, 'bg': None})
|
||
|
||
except Exception as e:
|
||
print(f"[Error] draw {idx}: {e}")
|
||
|
||
if page_border != "None":
|
||
full_rect = [margin, margin, width_page_int - margin, height_page_int - margin]
|
||
if "Rounded" in page_border:
|
||
self.draw_dashed_rounded_rectangle_manual(draw, full_rect, 10, dash_page, 2, border_color)
|
||
else:
|
||
self.draw_dashed_rectangle_manual(draw, full_rect, dash_page, 2, border_color)
|
||
|
||
# Draw Filename
|
||
for info in filename_draw_info:
|
||
if info['bg'] is not None:
|
||
draw.rectangle(info['rect'], fill=info['bg'])
|
||
draw.text(info['xy'], info['text'], font=info['font'], fill=info['fill'])
|
||
|
||
if img_mode == 'RGBA':
|
||
concat_np = np.array(concat).astype(np.float32) / 255.0
|
||
else:
|
||
concat_np = np.array(concat).astype(np.float32) / 255.0
|
||
if len(concat_np.shape) == 2:
|
||
concat_np = np.repeat(np.expand_dims(concat_np, -1), 3, -1)
|
||
return concat_np
|
||
|
||
def generate_concat(self, a1_image_dir, a2_page_width, a3_page_aspect_ratio, a4_cols_rows_per_page, a5_page_margin,
|
||
a6_title_padding,
|
||
a8_title_first_position, a7_title_draw_mode, a10_title_border, a11_title_border_style,
|
||
a12_page_border, a13_page_border_style, a97_title_save_mode, a98_title_save_dir,
|
||
a99_title_save_filename,
|
||
a9_background_style, a14_filename_position, a15_filename_color, a0_images=None):
|
||
|
||
self.image_dir_full = a1_image_dir
|
||
self.width_page_use_global = a2_page_width - 2 * a5_page_margin
|
||
|
||
filename_color_rgb = self.get_filename_color_by_name(a15_filename_color)
|
||
|
||
titles_final_path = ""
|
||
if a97_title_save_mode != "none":
|
||
mode_suffix = ""
|
||
if a97_title_save_mode == "save single title":
|
||
mode_suffix = "(1)"
|
||
elif a97_title_save_mode == "save single image":
|
||
mode_suffix = "(2)"
|
||
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
titles_final_path = os.path.join(a98_title_save_dir, f"concat_titles{mode_suffix}_{timestamp}").replace(
|
||
"\\", "/")
|
||
os.makedirs(titles_final_path, exist_ok=True)
|
||
else:
|
||
titles_final_path = "can't display `b5_title_save_path` due to `a97_title_save_mode` is 'none'"
|
||
|
||
# --- 新增:处理输入图像逻辑 ---
|
||
self.use_input_images = False
|
||
image_cache = {}
|
||
|
||
if a0_images is not None:
|
||
print(f"[✅ Detected input images batch. Batch size: {len(a0_images)}")
|
||
self.use_input_images = True
|
||
self.image_cache = {}
|
||
|
||
# 转换 Tensor -> PIL 列表
|
||
pil_images = []
|
||
for i in range(len(a0_images)):
|
||
# 提取单张图 -> 转换 -> 转 uint8 -> 转 PIL
|
||
img_tensor = a0_images[i]
|
||
i_np = img_tensor.cpu().numpy() * 255.0
|
||
i_np = i_np.clip(0, 255).astype(np.uint8)
|
||
pil_images.append(Image.fromarray(i_np))
|
||
|
||
# 生成虚拟文件名并存入缓存
|
||
image_files = [f"input_img_{i + 1:05d}.png" for i in range(len(pil_images))]
|
||
for name, pil_img in zip(image_files, pil_images):
|
||
self.image_cache[name] = pil_img
|
||
|
||
image_count_in_dir = len(image_files)
|
||
# 覆盖文件夹路径,防止后续逻辑报错(虽然输入模式下不检查路径)
|
||
self.image_dir_full = "Input_Stream"
|
||
|
||
elif os.path.exists(a1_image_dir):
|
||
# 原有逻辑:从文件夹读取
|
||
image_extensions = ('.png', '.jpg', '.jpeg', '.bmp', '.gif', '.tiff', '.webp')
|
||
image_files = [f for f in os.listdir(a1_image_dir) if f.lower().endswith(image_extensions)]
|
||
image_count_in_dir = len(image_files)
|
||
else:
|
||
print(f"[Error] 图片文件夹不存在: {a1_image_dir} 且无输入图像")
|
||
error_img = np.zeros((1, 100, 100, 3), dtype=np.float32)
|
||
error_img[:, :, :, 0] = 1.0
|
||
return (torch.from_numpy(error_img), 0, "0×0", 0, titles_final_path, self.get_node_tips())
|
||
|
||
if image_count_in_dir == 0:
|
||
print("[Error] 无有效图片")
|
||
error_img = np.zeros((1, 100, 100, 3), dtype=np.float32)
|
||
error_img[:, :, :, 0] = 1.0
|
||
error_img[:, :, :, 1] = 1.0
|
||
return (torch.from_numpy(error_img), 0, "0×0", 0, titles_final_path, self.get_node_tips())
|
||
|
||
title_ratio = round(self.convert_ratio_to_float(a3_page_aspect_ratio), 2)
|
||
height_page = int(a2_page_width / title_ratio)
|
||
print(f"[✅] 画布尺寸: {a2_page_width} × {height_page} | 宽高比: {a3_page_aspect_ratio}")
|
||
|
||
width_page_use = a2_page_width - 2 * a5_page_margin
|
||
height_page_use = height_page - 2 * a5_page_margin
|
||
|
||
is_a4_equals_1 = (a4_cols_rows_per_page == 1)
|
||
is_start_from_margin = (a8_title_first_position == "start_from margin")
|
||
has_outer_padding = (not is_start_from_margin)
|
||
is_vert_center = (a8_title_first_position == "start_from margin + padding(vertical centering)")
|
||
|
||
equal_width_mode = a7_title_draw_mode == "5.equal title width up_down"
|
||
equal_height_mode = a7_title_draw_mode == "6.equal title height left_right"
|
||
page_group_count = {}
|
||
page_total_occupy_height = []
|
||
page_image_mapping = {}
|
||
page_data_list = []
|
||
w_title_size = 100
|
||
h_title_size = 100
|
||
wh_per_title = ""
|
||
n_per_col_actual = 1
|
||
|
||
if is_a4_equals_1:
|
||
page_data_list = self.calc_vertical_title_groups_a4_1(
|
||
image_files, width_page_use, height_page_use,
|
||
a6_title_padding, a8_title_first_position, a7_title_draw_mode
|
||
)
|
||
for i in range(len(page_data_list)):
|
||
page_group_count[i] = 1
|
||
page_total_occupy_height.append(height_page_use)
|
||
page_image_mapping[i] = page_data_list[i]['files']
|
||
|
||
m = page_data_list[0]['meta'] if page_data_list else {}
|
||
if m.get('type') == 'square':
|
||
w_title_size_int = m['size']
|
||
h_title_size_int = m['size']
|
||
w_title_size = w_title_size_int
|
||
h_title_size = h_title_size_int
|
||
wh_per_title = f"title width = {w_title_size_int}\nequal title height = {h_title_size_int}"
|
||
elif m.get('type') == 'fixed_width':
|
||
w_title_size_int = m['width']
|
||
h_title_size_int = m['width']
|
||
w_title_size = w_title_size_int
|
||
h_title_size = w_title_size_int
|
||
wh_per_title = f"equal title width = {w_title_size_int}"
|
||
elif m.get('type') == 'fixed_height':
|
||
w_title_size_int = m['height']
|
||
h_title_size_int = m['height']
|
||
w_title_size = h_title_size_int
|
||
h_title_size = h_title_size_int
|
||
wh_per_title = f"equal title height = {h_title_size_int}"
|
||
else:
|
||
w_title_size = 100
|
||
h_title_size = 100
|
||
w_title_size_int = 100
|
||
h_title_size_int = 100
|
||
wh_per_title = f"{w_title_size_int}×{h_title_size_int}"
|
||
|
||
else:
|
||
if is_start_from_margin:
|
||
w_title_size = (width_page_use - (a4_cols_rows_per_page - 1) * a6_title_padding) / a4_cols_rows_per_page
|
||
else:
|
||
w_title_size = (width_page_use - (a4_cols_rows_per_page + 1) * a6_title_padding) / a4_cols_rows_per_page
|
||
|
||
w_title_size_int = int(w_title_size)
|
||
|
||
if a7_title_draw_mode in ["1.smaller value filler", "2.Stretches image to fill",
|
||
"3.zoom by long side (recommended)", "4.crop square by short side"]:
|
||
h_title_size_int = w_title_size_int
|
||
else:
|
||
h_title_size_int = max(1, int(w_title_size / self.convert_ratio_to_float(a3_page_aspect_ratio)))
|
||
|
||
h_title_size = h_title_size_int
|
||
|
||
if equal_width_mode:
|
||
_, _, all_title_groups, _ = self.calc_vertical_title_groups(image_files, height_page_use,
|
||
a6_title_padding,
|
||
a8_title_first_position, w_title_size_int,
|
||
a4_cols_rows_per_page)
|
||
total_groups = len(all_title_groups)
|
||
groups_per_page = a4_cols_rows_per_page
|
||
total_pages = (total_groups + groups_per_page - 1) // groups_per_page if total_groups > 0 else 1
|
||
for page_idx in range(total_pages):
|
||
start_g = page_idx * groups_per_page
|
||
end_g = min(start_g + groups_per_page, total_groups)
|
||
page_groups = all_title_groups[start_g:end_g]
|
||
page_images = []
|
||
for g in page_groups: page_images.extend(g)
|
||
page_image_mapping[page_idx] = page_images
|
||
page_group_count[page_idx] = len(page_groups)
|
||
page_total_occupy_height.append(height_page_use)
|
||
wh_per_title = f"equal title width = {w_title_size_int}"
|
||
elif equal_height_mode:
|
||
w_diff_title_size, w_row_group_size, row_groups, page_row_mapping, page_total_occupy_height_calc, _ = self.calc_horizontal_row_groups(
|
||
image_files, width_page_use, height_page_use, a6_title_padding, a8_title_first_position,
|
||
h_title_size_int,
|
||
a4_cols_rows_per_page
|
||
)
|
||
total_pages = len(page_row_mapping)
|
||
page_total_occupy_height = page_total_occupy_height_calc
|
||
for page_idx in range(total_pages):
|
||
page_rows = page_row_mapping[page_idx]
|
||
page_images = []
|
||
for r in page_rows: page_images.extend(r)
|
||
page_image_mapping[page_idx] = page_images
|
||
page_group_count[page_idx] = len(page_rows)
|
||
wh_per_title = f"equal title height = {h_title_size_int}"
|
||
else:
|
||
n_per_col = 1
|
||
for n in range(100, 0, -1):
|
||
h_sum = n * h_title_size_int + (n - 1) * a6_title_padding
|
||
if has_outer_padding:
|
||
h_sum += (n + 1) * a6_title_padding
|
||
if h_sum <= height_page_use:
|
||
n_per_col = n
|
||
break
|
||
if n_per_col == 0: n_per_col = 1
|
||
n_per_col_actual = n_per_col
|
||
|
||
titles_per_page = a4_cols_rows_per_page * n_per_col
|
||
image_pages = [image_files[i:i + titles_per_page] for i in range(0, len(image_files), titles_per_page)]
|
||
total_pages = len(image_pages)
|
||
for page_idx in range(total_pages):
|
||
page_image_mapping[page_idx] = image_pages[page_idx]
|
||
page_group_count[page_idx] = 1
|
||
page_total_occupy_height.append(height_page_use)
|
||
|
||
wh_per_title = f"title width = {w_title_size_int}\nequal title height = {h_title_size_int}"
|
||
|
||
print(
|
||
f"[✅分页信息] 模式: {a7_title_draw_mode} | 通用队列数: {a4_cols_rows_per_page} | 总页数: {len(page_image_mapping)} | 块尺寸: {wh_per_title}")
|
||
|
||
all_concats = []
|
||
vertical_offset_mode = a8_title_first_position == "start_from margin + padding(vertical centering)"
|
||
|
||
for page_idx in range(len(page_image_mapping)):
|
||
current_page_num = page_idx + 1
|
||
|
||
global_start_idx = 0
|
||
for i in range(page_idx):
|
||
global_start_idx += len(page_image_mapping[i])
|
||
|
||
current_group_cnt = page_group_count.get(page_idx, 1)
|
||
current_page_h = page_total_occupy_height[page_idx] if page_idx < len(
|
||
page_total_occupy_height) else height_page_use
|
||
|
||
if isinstance(page_image_mapping[page_idx][0], int):
|
||
page_image_files = [image_files[idx] for idx in page_image_mapping[page_idx]]
|
||
else:
|
||
page_image_files = page_image_mapping[page_idx]
|
||
|
||
print(
|
||
f"\n{'=' * 50} 绘制第 {current_page_num}/{len(page_image_mapping)} 页 (块组数: {current_group_cnt}) {'=' * 50}")
|
||
|
||
n_per_col_arg = 1
|
||
if not equal_height_mode and not equal_width_mode:
|
||
n_per_col_arg = n_per_col_actual
|
||
elif equal_height_mode:
|
||
n_per_col_arg = 9999
|
||
|
||
concat_page_np = self.create_single_concat_page(
|
||
page_image_files, a2_page_width, height_page, a4_cols_rows_per_page,
|
||
n_per_col_arg,
|
||
a5_page_margin, a6_title_padding, a8_title_first_position,
|
||
w_title_size, h_title_size, a7_title_draw_mode, a10_title_border, a11_title_border_style,
|
||
a12_page_border, a13_page_border_style, current_page_num,
|
||
a97_title_save_mode, titles_final_path, a99_title_save_filename, global_start_idx,
|
||
a9_background_style,
|
||
vertical_offset_mode, image_count_in_dir,
|
||
current_page_group_count=current_group_cnt,
|
||
page_total_occupy_h=current_page_h,
|
||
add_filename=a14_filename_position,
|
||
filename_color=filename_color_rgb,
|
||
page_meta=page_data_list[page_idx]['meta'] if is_a4_equals_1 and page_idx < len(
|
||
page_data_list) else None
|
||
)
|
||
all_concats.append(concat_page_np)
|
||
|
||
concat_np = np.stack(all_concats, axis=0) if all_concats else np.zeros((1, 100, 100, 3), dtype=np.float32)
|
||
concat_tensor = torch.from_numpy(concat_np)
|
||
|
||
return (concat_tensor, len(page_image_mapping), wh_per_title, image_count_in_dir, titles_final_path,
|
||
self.get_node_tips())
|
||
|
||
|
||
NODE_CLASS_MAPPINGS["ImageConcatNode"] = ImageConcatNode
|
||
NODE_DISPLAY_NAME_MAPPINGS["ImageConcatNode"] = "Image concatenate(V1.2 QQ2540968810)"
|
||
|
||
if __name__ == "__main__":
|
||
print("✅ Comfyui-Image-Concat(V1.2) Registration successful!")
|
||
|