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!")