import os from PIL import Image, ImageDraw, ImageFont import numpy as np import torch import logging class TextOnImage: def __init__(self): pass @classmethod def INPUT_TYPES(s): font_dir = os.path.join(os.path.dirname(__file__), "font") font_files = [f for f in os.listdir(font_dir) if f.endswith(('.ttc', '.ttf'))] if not font_files: font_files = ["default"] return { "required": { "text": ("STRING", {"default": "", "multiline": True}), "image": ("IMAGE",), "x": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), "y": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), "font_size": ("INT", {"default": 12, "min": 0, "max": 320, "step": 1}), "text_color": ("STRING", {"default": "#ffffff"}), "text_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), "stroke_width": ("INT", {"default": 0, "min": 0, "max": 100, "step": 1}), "stroke_color": ("STRING", {"default": "#000000"}), "stroke_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), "shadow_x": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}), "shadow_y": ("INT", {"default": 0, "min": -100, "max": 100, "step": 1}), "shadow_color": ("STRING", {"default": "#000000"}), "shadow_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}), "font_file": (font_files, {"default": font_files[0]}), }, } RETURN_TYPES = ("IMAGE",) FUNCTION = "apply_text" CATEGORY = "💀Text On Image" def hex_to_rgb(self, hex_color): """将 HEX 色值转换为 RGB 元组""" hex_color = hex_color.lstrip('#') return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4)) def apply_text(self, text, image, x, y, font_size, text_color, text_opacity, stroke_width, stroke_color, stroke_opacity, shadow_x, shadow_y, shadow_color, shadow_opacity, font_file): if text == "": logging.info("Text is empty, returning original image.") return (image,) # 将输入的 torch.Tensor 转换为 PIL 图像 img = image[0].cpu().numpy() img = (img * 255).astype(np.uint8) base_img = Image.fromarray(img).convert('RGBA') img_width, img_height = base_img.size logging.info(f"Image size: {img_width}x{img_height}") # 构建字体文件路径 font_dir = os.path.join(os.path.dirname(__file__), "font") font_path = os.path.join(font_dir, font_file) logging.info(f"Font path: {font_path}") # 加载字体 try: font = ImageFont.truetype(font_path, font_size) logging.info(f"Font loaded successfully: {font_path}") except IOError as e: logging.warning(f"Failed to load font {font_path}: {e}, using default font.") font = ImageFont.load_default() # 转换颜色 text_color_rgb = self.hex_to_rgb(text_color) stroke_color_rgb = self.hex_to_rgb(stroke_color) shadow_color_rgb = None if shadow_color == "none" else self.hex_to_rgb(shadow_color) # 使用 ImageDraw.textbbox 精确计算文字区域 temp_img = Image.new('RGBA', (1, 1), (0, 0, 0, 0)) temp_draw = ImageDraw.Draw(temp_img) text_bbox = temp_draw.textbbox((0, 0), text, font=font) text_width = text_bbox[2] - text_bbox[0] text_height = text_bbox[3] - text_bbox[1] # 增加安全高度 safety_margin = font_size // 2 text_height += safety_margin logging.info(f"Text dimensions: width={text_width}, height={text_height}") # 计算总padding(包括描边和投影) padding = max(stroke_width * 2, abs(shadow_x), abs(shadow_y)) # 调整文字位置 text_left = max(0, x - padding) text_top = max(0, y - padding) text_right = min(img_width, x + text_width + padding) text_bottom = min(img_height, y + text_height + padding) # 如果文字超出图像底部,向上调整 if text_bottom > img_height: offset = text_bottom - img_height text_top = max(0, text_top - offset) text_bottom = img_height logging.info(f"Canvas bounds: left={text_left}, top={text_top}, right={text_right}, bottom={text_bottom}") # 创建文字层 canvas_width = text_right - text_left canvas_height = text_bottom - text_top if canvas_width <= 0 or canvas_height <= 0: logging.warning("Canvas dimensions are invalid, returning original image.") return (image,) canvas = Image.new('RGBA', (canvas_width, canvas_height), (0, 0, 0, 0)) canvas_draw = ImageDraw.Draw(canvas) logging.info(f"Canvas created: {canvas_width}x{canvas_height}") # 计算文字在画布中的相对位置(y 作为顶部位置) relative_x = x - text_left relative_y = y - text_top logging.info(f"Relative text position: x={relative_x}, y={relative_y}") # 绘制投影 if shadow_color_rgb and (shadow_x != 0 or shadow_y != 0): try: shadow_pos = (relative_x + shadow_x, relative_y + shadow_y) canvas_draw.text(shadow_pos, text, font=font, fill=shadow_color_rgb + (int(255 * shadow_opacity),)) logging.info("Shadow drawn successfully.") except Exception as e: logging.error(f"Failed to draw shadow: {e}") # 绘制描边 if stroke_width > 0: try: for dx in range(-stroke_width, stroke_width + 1): for dy in range(-stroke_width, stroke_width + 1): if dx != 0 or dy != 0: stroke_pos = (relative_x + dx, relative_y + dy) canvas_draw.text(stroke_pos, text, font=font, fill=stroke_color_rgb + (int(255 * stroke_opacity),)) logging.info(f"Stroke drawn with width {stroke_width}.") except Exception as e: logging.error(f"Failed to draw stroke: {e}") # 绘制主文字 try: canvas_draw.text((relative_x, relative_y), text, font=font, fill=text_color_rgb + (int(255 * text_opacity),)) logging.info("Main text drawn successfully.") except Exception as e: logging.error(f"Failed to draw main text: {e}") return (image,) # 将文字画布粘贴到主图像上 try: base_img.paste(canvas, (text_left, text_top), canvas) logging.info("Canvas pasted to base image successfully.") except Exception as e: logging.error(f"Failed to paste canvas to base image: {e}") return (image,) # 将 PIL.Image 转换回 torch.Tensor img = np.array(base_img).astype(np.float32) / 255.0 img = torch.from_numpy(img)[None,] logging.info("Image converted back to tensor.") return (img,) # 注册节点 NODE_CLASS_MAPPINGS = { "TextOnImage": TextOnImage } NODE_DISPLAY_NAME_MAPPINGS = { "TextOnImage": "💀Text On Image" }