Files
2026-01-19 23:24:15 +08:00

1697 lines
87 KiB
Python
Raw Permalink 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.
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!")