diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..81a17f8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Raykosan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..eeea0d0 --- /dev/null +++ b/README.md @@ -0,0 +1,96 @@ +## 🦊 ComfyUI_RaykoStudio +ComfyUI_RaykoStudio — это набор кастомных нод для ComfyUI, предоставляющий дополнительные возможности для работы с изображениями + +![Demo](web/preview.png) + +## 🦊 RS_RusTextOverlay Node + +## 🔥 Описание + +`RS_RusTextOverlay` позволяет накладывать текст на изображение в области, заданной маской. Нода поддерживает поворот текста в соответствии с углом наклона маски, автоматический подбор размера шрифта, кастомные шрифты, перенос строк и настройку прозрачности текста. + +### Возможности + +- Наложение текста на изображение в области, заданной маской. +- Автоматический поворот текста в соответствии с углом наклона маски (опционально). +- Поддержка кастомных шрифтов (`.ttf` или `.otf`), загружаемых из папки `fonts`. +- Автоматический подбор размера шрифта для вписывания текста в область маски. +- Настройка цвета текста и прозрачности (от 0 до 100%). +- Настройка выравнивания текста (вертикальное: top/center/bottom, горизонтальное: left/center/right). +- Настройка отступов (padding) вокруг текста. + +### Входы + +- `image`: Изображение (тензор формата RGB). +- `mask`: Маска (тензор формата L), определяющая область для текста. + +### Выходы + +- `IMAGE`: Изображение с наложенным текстом (тензор формата RGB). + +## ⚙️ Параметры + +- `text`: Текст для наложения. Поддерживает перенос строк. +- `font_name`: Выберите шрифт из списка доступных в папке `fonts` (или используйте `default`). +- `text_color`: Цвет текста (в формате HEX, например `#FFFFFF`). +- `text_opacity`: Прозрачность текста (0–100%, где 0 — полностью прозрачный, 100 — полностью непрозрачный). +- `min_font_size`: Минимальный размер шрифта (если текст не помещается, используется шрифт по умолчанию). +- `padding`: Отступы вокруг текста внутри маски. +- `vertical_align`: Вертикальное выравнивание текста (top/center/bottom). +- `horizontal_align`: Горизонтальное выравнивание текста (left/center/right). +- `rotate_with_mask`: Включить/выключить поворот текста в соответствии с углом наклона маски. + +## 🛠 Установка + +1. Клонируйте репозиторий в папку `ComfyUI/custom_nodes/`: + +git clone [https://github.com/Raykosan/ComfyUI_RaykoStudio.git](https://github.com/Raykosan/ComfyUI_RaykoStudio) + +Перезагрузите ComfyU + +или + +скопируйте папку RaykoStudio_Nodes в папку: ComfyUI/custom_nodes/ + +Перезагрузите ComfyU + +3. Убедитесь, что папка `fonts` содержит шрифты (`.ttf` или `.otf`). Вы можете добавить свои шрифты в эту папку. Если папка пуста или отсутствует, будет использоваться шрифт по умолчанию. + +4. Установите необходимые зависимости, указанные в `requirements.txt`: pip install -r requirements.txt + Перезапустите ComfyUI, чтобы нода стала доступна. + +## ⛓️‍💥 Зависимости + +Для работы ноды требуются следующие библиотеки (указаны в requirements.txt): + + torch>=1.7.0 + numpy>=1.19.0 + Pillow>=8.0.0 + opencv-python>=4.5.0 + +## 🎛 Использование + +После установки найдите ноду в ComfyUI под названием 🦊 RS_RusTextOverlay (категория: 🦊 RaykoStudio/Image). +Подключите входные данные (image и mask) и настройте параметры. +Пример рабочего процесса можно найти в папке examples: +- example workflow.json: Пример рабочего процесса для ComfyUI. +- example.png: Скриншот результата. + + +## 🤝 Сообщение об ошибках (Issues) + +Если вы столкнулись с проблемой или нашли баг: + +Проверьте раздел Issues на GitHub, возможно, проблема уже известна. +Если проблема новая, создайте новый Issue, описав: +- Версию ComfyUI и Python. +- Описание проблемы и шаги для ее воспроизведения. +- Скриншоты или логи ошибок (если есть). + +## 📜 Лицензия + +MIT License. Используйте ноду на свое усмотрение, но без каких-либо гарантий. + +## ❤️ Благодарности + +Спасибо сообществу ComfyUI за вдохновение и поддержку! Если вам понравилась эта нода, не забудьте поставить звездочку на GitHub! diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..739b13d --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes.russian_text import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/fonts/Abibas.otf b/fonts/Abibas.otf new file mode 100644 index 0000000..dead869 Binary files /dev/null and b/fonts/Abibas.otf differ diff --git a/fonts/Adriatica.ttf b/fonts/Adriatica.ttf new file mode 100644 index 0000000..05a658d Binary files /dev/null and b/fonts/Adriatica.ttf differ diff --git a/fonts/BalkaraFreeCondensed.ttf b/fonts/BalkaraFreeCondensed.ttf new file mode 100644 index 0000000..c2735ec Binary files /dev/null and b/fonts/BalkaraFreeCondensed.ttf differ diff --git a/fonts/Blood Cyrillic.ttf b/fonts/Blood Cyrillic.ttf new file mode 100644 index 0000000..7dbd68d Binary files /dev/null and b/fonts/Blood Cyrillic.ttf differ diff --git a/fonts/Calligrapher Regular.ttf b/fonts/Calligrapher Regular.ttf new file mode 100644 index 0000000..93f09f2 Binary files /dev/null and b/fonts/Calligrapher Regular.ttf differ diff --git a/fonts/DeusEx.otf b/fonts/DeusEx.otf new file mode 100644 index 0000000..e773f5e Binary files /dev/null and b/fonts/DeusEx.otf differ diff --git a/fonts/Lavka 2021.otf b/fonts/Lavka 2021.otf new file mode 100644 index 0000000..748b9ff Binary files /dev/null and b/fonts/Lavka 2021.otf differ diff --git a/fonts/NexaScript-Free.otf b/fonts/NexaScript-Free.otf new file mode 100644 index 0000000..b1312ab Binary files /dev/null and b/fonts/NexaScript-Free.otf differ diff --git a/fonts/Uni Sans Book.ttf b/fonts/Uni Sans Book.ttf new file mode 100644 index 0000000..d79a114 Binary files /dev/null and b/fonts/Uni Sans Book.ttf differ diff --git a/fonts/XTypewriter-Bold.otf b/fonts/XTypewriter-Bold.otf new file mode 100644 index 0000000..fbd49e4 Binary files /dev/null and b/fonts/XTypewriter-Bold.otf differ diff --git a/fonts/XTypewriter-Regular.otf b/fonts/XTypewriter-Regular.otf new file mode 100644 index 0000000..4395a3a Binary files /dev/null and b/fonts/XTypewriter-Regular.otf differ diff --git a/fonts/alkash.otf b/fonts/alkash.otf new file mode 100644 index 0000000..983af89 Binary files /dev/null and b/fonts/alkash.otf differ diff --git a/fonts/arial.ttf b/fonts/arial.ttf new file mode 100644 index 0000000..8682d94 Binary files /dev/null and b/fonts/arial.ttf differ diff --git a/fonts/facon.otf b/fonts/facon.otf new file mode 100644 index 0000000..27b104b Binary files /dev/null and b/fonts/facon.otf differ diff --git a/fonts/kudry_weird-headline.otf b/fonts/kudry_weird-headline.otf new file mode 100644 index 0000000..002bc11 Binary files /dev/null and b/fonts/kudry_weird-headline.otf differ diff --git a/fonts/Рукопись.ttf b/fonts/Рукопись.ttf new file mode 100644 index 0000000..5a4cf18 Binary files /dev/null and b/fonts/Рукопись.ttf differ diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..58a8b61 --- /dev/null +++ b/manifest.json @@ -0,0 +1,10 @@ +{ + "name": "🦊 RS_RusTextOverlay", + "author": "RaykoStudio", + "version": "1.0.0", + "comfyui": "0.3.27", + "description": "A node for ComfyUI that allows you to overlay text on an image in an area defined by a mask, with support for text rotation, custom fonts, line breaks and transparency.", + "node_classes": ["RS_RusTextOverlay"], + "tags": ["image", "mask", "text", "overlay", "adjustment"], + "requirements": ["torch>=2.5.0"] +} \ No newline at end of file diff --git a/nodes/__init__.py b/nodes/__init__.py new file mode 100644 index 0000000..e47a47f --- /dev/null +++ b/nodes/__init__.py @@ -0,0 +1,11 @@ +from .russian_text import RS_RusTextOverlay + +NODE_CLASS_MAPPINGS = { + "RS_RusTextOverlay": RS_RusTextOverlay +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RS_RusTextOverlay": "🦊 RS Русский Текст" +} + +__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS'] \ No newline at end of file diff --git a/nodes/russian_text.py b/nodes/russian_text.py new file mode 100644 index 0000000..def2a46 --- /dev/null +++ b/nodes/russian_text.py @@ -0,0 +1,244 @@ +# nodes/russian_text.py +import os +import torch +import numpy as np +from PIL import Image, ImageDraw, ImageFont +import cv2 + +class RS_RusTextOverlay: + def __init__(self): + print("\033[93m🦊\033[0m \033[93mRaykoStudio - Russian Text Overlay Node \033[92mLOADED\033[0m") + + @classmethod + def INPUT_TYPES(cls): + current_dir = os.path.dirname(os.path.abspath(__file__)) + fonts_dir = os.path.join(os.path.dirname(current_dir), "fonts") + + font_list = [] + if os.path.exists(fonts_dir): + for f in os.listdir(fonts_dir): + if f.lower().endswith(('.ttf', '.otf')): + font_list.append(f) + + if not font_list: + font_list = ["default"] + print("RS_RusTextOverlay: No fonts found, using default") + + return { + "required": { + "image": ("IMAGE",), + "mask": ("MASK",), + "text": ("STRING", { + "default": "Текст\nс переносом\tи табуляцией", + "multiline": True + }), + "font_name": (font_list, {"default": font_list[0]}), + "text_color": ("COLOR", {"default": "#FFFFFF"}), + "text_opacity": ("INT", {"default": 100, "min": 0, "max": 100}), + "min_font_size": ("INT", {"default": 8, "min": 1, "max": 500}), + "padding": ("INT", {"default": 5, "min": 0, "max": 100}), + "vertical_align": (["top", "center", "bottom"], {"default": "center"}), + "horizontal_align": (["left", "center", "right"], {"default": "center"}), + "rotate_with_mask": ("BOOLEAN", {"default": True}), + }, + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "apply_text" + CATEGORY = "🦊 RaykoStudio/Image" + + def apply_text(self, image, mask, text, font_name, text_color, text_opacity, min_font_size, padding, vertical_align, horizontal_align, rotate_with_mask): + try: + # Конвертация tensor -> PIL + image_pil = Image.fromarray((image[0].cpu().numpy() * 255).astype(np.uint8), 'RGB') + mask_pil = Image.fromarray((mask[0].cpu().numpy() * 255).astype(np.uint8), 'L') + + # Получаем область маски + bbox = mask_pil.getbbox() + if not bbox: + return (image,) + + x1, y1, x2, y2 = bbox + mask_width = x2 - x1 - 2*padding + mask_height = y2 - y1 - 2*padding + + # Загрузка шрифта + if font_name != "default": + font_path = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "fonts", + font_name + ) + + font = self.find_optimal_font(font_path, text, mask_width, mask_height, min_font_size) + else: + font = ImageFont.load_default() + + # Преобразуем прозрачность (0-100%) в альфа-канал (0-255) + alpha = int((text_opacity / 100.0) * 255) + if text_color.startswith('#'): + text_color = text_color[1:] + r = int(text_color[0:2], 16) + g = int(text_color[2:4], 16) + b = int(text_color[4:6], 16) + text_color_rgba = (r, g, b, alpha) + + # Создаем текстовый слой + text_layer = Image.new("RGBA", image_pil.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(text_layer) + + # Разбиваем текст на строки + lines = text.split('\n') + line_heights = [] + line_widths = [] + for line in lines: + line = line.replace('\t', ' ') + text_bbox = draw.textbbox((0, 0), line, font=font) + line_widths.append(text_bbox[2] - text_bbox[0]) + line_heights.append(text_bbox[3] - text_bbox[1]) + + total_text_height = sum(line_heights) + max_text_width = max(line_widths) + + if rotate_with_mask: + mask_np = np.array(mask_pil) + contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + if contours: + contour = max(contours, key=cv2.contourArea) + points = contour.reshape(-1, 2).astype(np.float32) + mean = np.mean(points, axis=0) + cov = np.cov(points.T) + eigenvalues, eigenvectors = np.linalg.eigh(cov) + main_axis = eigenvectors[:, np.argmax(eigenvalues)] + angle = np.arctan2(main_axis[1], main_axis[0]) * 180 / np.pi + + leftmost = points[np.argmin(points[:, 0])] + rightmost = points[np.argmax(points[:, 0])] + direction_vector = rightmost - leftmost + direction_angle = np.arctan2(direction_vector[1], direction_vector[0]) * 180 / np.pi + + angle_diff = (direction_angle - angle) % 360 + if angle_diff > 90 and angle_diff < 270: + angle += 180 + angle = angle % 360 + + if 90 < angle <= 270: + angle += 180 + angle = angle % 360 + + angle = -angle + + temp_size = int(max(max_text_width, total_text_height) * 3) + temp_text = Image.new("RGBA", (temp_size, temp_size), (0, 0, 0, 0)) + temp_draw = ImageDraw.Draw(temp_text) + + center_x, center_y = temp_size // 2, temp_size // 2 + + current_y = center_y - total_text_height // 2 + for line, line_height, line_width in zip(lines, line_heights, line_widths): + line = line.replace('\t', ' ') + pos_x = center_x - line_width // 2 + temp_draw.text( + (pos_x, current_y), + line, + font=font, + fill=text_color_rgba, + anchor="lt" + ) + current_y += line_height + + rotated_text = temp_text.rotate( + angle, + center=(center_x, center_y), + resample=Image.BICUBIC, + expand=False + ) + + rot_bbox = rotated_text.getbbox() + if rot_bbox: + rot_width = rot_bbox[2] - rot_bbox[0] + rot_height = rot_bbox[3] - rot_bbox[1] + + mask_center_x = (x1 + x2) / 2 + mask_center_y = (y1 + y2) / 2 + + paste_x = int(mask_center_x - rot_width / 2) + paste_y = int(mask_center_y - rot_height / 2) + + text_layer.paste( + rotated_text.crop(rot_bbox), + (paste_x, paste_y), + rotated_text.crop(rot_bbox) + ) + else: + print("Ошибка: не удалось определить bounding box повернутого текста") + else: + if vertical_align == "top": + current_y = y1 + padding + elif vertical_align == "bottom": + current_y = y2 - total_text_height - padding + else: + current_y = y1 + (mask_height - total_text_height) // 2 + padding + + for line, line_height, line_width in zip(lines, line_heights, line_widths): + line = line.replace('\t', ' ') + pos_x = { + "left": x1 + padding, + "right": x2 - line_width - padding, + "center": x1 + (mask_width - line_width) // 2 + padding + }[horizontal_align] + draw.text( + (pos_x, current_y), + line, + font=font, + fill=text_color_rgba, + anchor="lt" + ) + current_y += line_height + + result = Image.alpha_composite( + image_pil.convert("RGBA"), + text_layer + ).convert("RGB") + + return (torch.from_numpy(np.array(result).astype(np.float32) / 255.0).unsqueeze(0),) + + except Exception as e: + print(f"Ошибка в RS_RusTextOverlay: {str(e)}") + return (image,) + + def find_optimal_font(self, font_path, text, max_width, max_height, min_size): + temp_img = Image.new("RGB", (max_width, max_height)) + draw = ImageDraw.Draw(temp_img) + + lines = text.split('\n') + for size in range(500, min_size - 1, -1): + try: + font = ImageFont.truetype(font_path, size) + fits = True + total_height = 0 + max_line_width = 0 + for line in lines: + line = line.replace('\t', ' ') + bbox = draw.textbbox((0, 0), line, font=font) + line_width = bbox[2] - bbox[0] + line_height = bbox[3] - bbox[1] + total_height += line_height + max_line_width = max(max_line_width, line_width) + if line_width > max_width: + fits = False + break + if fits and total_height <= max_height: + return font + except Exception as e: + print(f"RS_RusTextOverlay: Failed to load font {font_path} with size {size}: {str(e)}") # Отладка: ошибка загрузки шрифта + continue + return ImageFont.load_default() + +NODE_CLASS_MAPPINGS = { + "RS_RusTextOverlay": RS_RusTextOverlay +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "RS_RusTextOverlay": "🦊 RS RusTextOverlay" +} \ No newline at end of file diff --git a/package.json b/package.json new file mode 100644 index 0000000..e200e4d --- /dev/null +++ b/package.json @@ -0,0 +1,16 @@ +{ + "name": "comfyui-rs-text-overlay-node", + "version": "1.0.0", + "description": "A node for ComfyUI that allows you to overlay text on an image in an area defined by a mask, with support for text rotation, custom fonts, line breaks and transparency.", + "author": "Raykosan", + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/Raykosan/ComfyUI_RaykoStudio" + }, + "comfyui": { + "nodes": [ + "nodes/russian_text.py" + ] + } +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cb1cdbe --- /dev/null +++ b/requirements.txt @@ -0,0 +1,4 @@ +torch>=2.0.0 +Pillow>=8.0.0 +numpy>=1.19.0 +opencv-python-headless>=4.2.0 \ No newline at end of file diff --git a/web/preview.png b/web/preview.png new file mode 100644 index 0000000..83cd8e8 Binary files /dev/null and b/web/preview.png differ