commit 9e2b6865a7688a72a907862eb518ef78061b2efa Author: Munkyfoot Date: Tue Feb 27 14:40:19 2024 -0800 Initial commit. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..aa7f797 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +fonts/* \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..35e10a0 --- /dev/null +++ b/README.md @@ -0,0 +1,50 @@ +# TextOverlay Node for ComfyUI + +The TextOverlay node allows users to overlay text on images. The node supports adjustments for font size, type, color, alignment, and position, making it versatile for various applications. + +Originally inspired by the [Text Overlay Plugin by mikkel](https://github.com/mikkel/ComfyUI-text-overlay), this node has been rebuilt and expanded to include additional features and improvements. + +## Features + +- **Font Customization**: Choose from different font sizes and types to match your design. +- **Color Selection**: Specify text and stroke colors in hex format for perfect matching. +- **Alignment and Positioning**: Align your text horizontally and vertically, with options for precise position adjustments. +- **Stroke Customization**: Add a stroke to your text with customizable color and thickness. +- **Supports Single and Batch Processing**: Process single images or multiple images in a batch. + +## Installation + +To use the TextOverlay node, ensure this repo is included in your ComfyUI `custom_nodes` directory. + +## Usage + +After installation, the TextOverlay node will be available in the ComfyUI interface under the `image/text` category. To use the node: + +1. Add the TextOverlay node to your workflow. +2. Connect an image source to the node's image input. +3. Configure the node parameters in the UI, including text content, font specifications, color, alignment, and positioning adjustments. +4. Connect the node's output to your desired destination in the workflow. + +### Custom Fonts + +To use a custom font, ensure the font file is included in the `fonts` directory within the `custom_nodes` directory. Then, specify the font file name (e.g., `arial.ttf`) in the `font` parameter of the TextOverlay node. If using a system-installed font, you can specify the font name directly without the file path. If the font file is not found, the node will default to a system-installed font. + +## Input Parameters + +The TextOverlay node requires the following inputs: + +- `image`: The input image(s) to overlay text on. +- `text`: The text content to overlay. +- `font_size`: Size of the font. +- `font`: Name of the font file (e.g., `arial.ttf`). +- `fill_color_hex`: Hex code for the text color. +- `stroke_color_hex`: Hex code for the text stroke color. +- `stroke_thickness`: Thickness of the stroke. +- `padding`: Padding around the text. +- `horizontal_alignment`: Horizontal text alignment (`left`, `center`, `right`). +- `vertical_alignment`: Vertical text alignment (`top`, `middle`, `bottom`). +- `x_shift`, `y_shift`: Adjustments for text positioning. + +## Output + +The node overlays the specified text according to the configured parameters and output the modified input image(s). diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9c7aad1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS"] diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..388f240 --- /dev/null +++ b/nodes.py @@ -0,0 +1,314 @@ +import os + +import numpy as np +import torch +from PIL import Image, ImageDraw, ImageFont + + +class TextOverlay: + """ + A node to overlay text on images with various customization options such as + font size, font type, text color, stroke color, stroke thickness, padding, + alignment, and position adjustments. + """ + + _horizontal_alignments = ["left", "center", "right"] + _vertical_alignments = ["top", "middle", "bottom"] + + def __init__(self, device="cpu"): + """ + Initializes the TextOverlay node. + + Parameters: + - device (str): The computing device to use, default is 'cpu'. + """ + + self.device = device + self._loaded_font = None + self._full_text = None + self._x = None + self._y = None + + @classmethod + def INPUT_TYPES(cls): + """ + Defines the expected input parameters for the text overlay functionality. + + Returns: + - dict: A dictionary specifying required inputs and their attributes. + """ + + return { + "required": { + "image": ("IMAGE",), # Input image to overlay text on + "text": ( + "STRING", + {"multiline": True, "default": "Hello"}, + ), # Text to overlay + "font_size": ( + "INT", + {"default": 32, "min": 1, "max": 256, "step": 1}, + ), # Font size + "font": ( + "STRING", + {"default": "arial.ttf"}, + ), # Font name (e.g. arial.ttf) + "fill_color_hex": ( + "STRING", + {"default": "#FFFFFF"}, + ), # Text fill color in hex + "stroke_color_hex": ( + "STRING", + {"default": "#000000"}, + ), # Text stroke color in hex + "stroke_thickness": ( + "FLOAT", + {"default": 0.2, "min": 0.0, "max": 1.0, "step": 0.01}, + ), # Stroke thickness + "padding": ( + "INT", + {"default": 16, "min": 0, "max": 128, "step": 1}, + ), # Padding around text + "horizontal_alignment": ( + cls._horizontal_alignments, + {"default": "center"}, + ), # Horizontal alignment + "vertical_alignment": ( + cls._vertical_alignments, + {"default": "bottom"}, + ), # Vertical alignment + "x_shift": ( + "INT", + {"default": 0, "min": -128, "max": 128, "step": 1}, + ), # Horizontal position adjustment + "y_shift": ( + "INT", + {"default": 0, "min": -128, "max": 128, "step": 1}, + ), # Vertical position adjustment + } + } + + # Static attributes defining the return type, function name, and category for the class + RETURN_TYPES = ("IMAGE",) + FUNCTION = "batch_process" + CATEGORY = "image/text" + + def hex_to_rgb(self, hex_color): + """ + Converts a hex color string to an RGB tuple. + + Parameters: + - hex_color (str): The color in hex format as a string. + + Returns: + - tuple: The color converted to an (R, G, B) tuple. + """ + + hex_color = hex_color.lstrip("#") + if len(hex_color) == 3: + hex_color = hex_color * 2 + return tuple(int(hex_color[i : i + 2], 16) for i in (0, 2, 4)) + + def draw_text( + self, + image, + text, + font_size, + font, + fill_color_hex, + stroke_color_hex, + stroke_thickness, + padding, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + use_cache=False, + ): + """ + Draws the specified text on the given image with the provided styling and alignment options. + + Parameters: + - image (PIL.Image.Image): The image to draw text on. + - text (str): The text to overlay on the image. + - font_size (int): The font size of the text. + - font (str): The font type of the text. + - fill_color_hex (str): The fill color of the text in hex format. + - stroke_color_hex (str): The stroke color of the text in hex format. + - stroke_thickness (float): The thickness of the text stroke. + - padding (int): The padding around the text. + - horizontal_alignment (str): The horizontal alignment of the text ('left', 'center', 'right'). + - vertical_alignment (str): The vertical alignment of the text ('top', 'middle', 'bottom'). + - x_shift (int): Horizontal position adjustment for the text. + - y_shift (int): Vertical position adjustment for the text. + - use_cache (bool): Flag to use cached font and position calculations to improve performance. + + Returns: + - PIL.Image.Image: The image with the text overlay applied. + """ + + # Load font from the fonts directory or use default if not found or specified to not use cache + if self._loaded_font is None or use_cache is False: + fonts_dir = os.path.join(os.path.dirname(__file__), "fonts") + font_path = os.path.join(fonts_dir, font) + + # Check if the font file exists in the fonts directory + if not os.path.exists(font_path): + # If not, set path to font name directly - this will cause PIL to search for the font in the system + font_path = font + + try: + self._loaded_font = ImageFont.truetype(font_path, font_size) + except Exception as e: + print(f"Error loading font: {e}... Using default font") + self._loaded_font = ImageFont.load_default() + + # Prepare to draw on the image + draw = ImageDraw.Draw(image) + + # Process text for multiline support and fit within image dimensions + words = text.replace("\n", "\n ").split(" ") + if self._full_text is None or use_cache is False: + text_lines, line = [], "" + for word in words: + extra_line = "\n" in word + word = word.strip() + if ( + draw.textlength(line + word, font=self._loaded_font) + < image.width - 2 * padding + ): + line += word + " " + else: + text_lines.append(line.strip()) + line = word + " " + if extra_line: + text_lines.append(line.strip()) + line = "" + text_lines.append(line.strip()) + self._full_text = "\n".join(text_lines) + + # Calculate text position based on alignment and position adjustments + if self._x is None or self._y is None or use_cache is False: + left, top, right, bottom = draw.multiline_textbbox( + (0, 0), + self._full_text, + font=self._loaded_font, + stroke_width=int(font_size * stroke_thickness * 0.5), + align=horizontal_alignment, + ) + if horizontal_alignment == "left": + self._x = padding + elif horizontal_alignment == "center": + self._x = (image.width - (right - left)) / 2 + elif horizontal_alignment == "right": + self._x = image.width - (right - left) - padding + self._x += x_shift + if vertical_alignment == "middle": + self._y = (image.height - (bottom - top)) / 2 + elif vertical_alignment == "top": + self._y = padding + elif vertical_alignment == "bottom": + self._y = image.height - (bottom - top) - padding + self._y += y_shift + + # Draw the processed text onto the image + draw.text( + (self._x, self._y), + self._full_text, + fill=self.hex_to_rgb(fill_color_hex), + stroke_fill=self.hex_to_rgb(stroke_color_hex), + stroke_width=int(font_size * stroke_thickness * 0.5), + font=self._loaded_font, + align=horizontal_alignment, + ) + return image + + def batch_process( + self, + image, + text, + font_size, + font, + fill_color_hex, + stroke_color_hex, + stroke_thickness, + padding, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + ): + """ + Processes a batch of images or a single image, adding the specified text overlay + with the given styling and alignment options. + + Parameters: + - image (torch.Tensor or numpy.ndarray): The image(s) to process. + - text (str): The text to overlay on the image(s). + - font_size (int): The font size of the text. + - font (str): The font type of the text. + - fill_color_hex (str): The fill color of the text in hex format. + - stroke_color_hex (str): The stroke color of the text in hex format. + - stroke_thickness (float): The thickness of the text stroke. + - padding (int): The padding around the text. + - horizontal_alignment (str): The horizontal alignment of the text. + - vertical_alignment (str): The vertical alignment of the text. + - x_shift (int): Horizontal position adjustment for the text. + - y_shift (int): Vertical position adjustment for the text. + + Returns: + - tuple: A tuple containing the processed image(s) as a torch.Tensor. + """ + + # Handles both single and batch image processing for text overlay + if len(image.shape) == 3: # Single image + image_np = image.cpu().numpy() + image = Image.fromarray((image_np.squeeze(0) * 255).astype(np.uint8)) + image = self.draw_text( + image, + text, + font_size, + font, + fill_color_hex, + stroke_color_hex, + stroke_thickness, + padding, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + ) + image_tensor_out = torch.tensor(np.array(image).astype(np.float32) / 255.0) + image_tensor_out = torch.unsqueeze(image_tensor_out, 0) + return (image_tensor_out,) + else: # Batch of images + image_np = image.cpu().numpy() + images = [Image.fromarray((img * 255).astype(np.uint8)) for img in image_np] + images_out, use_cache = [], False + for img in images: + img = self.draw_text( + img, + text, + font_size, + font, + fill_color_hex, + stroke_color_hex, + stroke_thickness, + padding, + horizontal_alignment, + vertical_alignment, + x_shift, + y_shift, + use_cache, + ) + images_out.append(np.array(img).astype(np.float32) / 255.0) + use_cache = True + images_np = np.stack(images_out) + images_tensor = torch.from_numpy(images_np) + return (images_tensor,) + + +# Mapping of node class names to their respective classes +NODE_CLASS_MAPPINGS = { + "Text Overlay": TextOverlay, +}