Update package.json
This commit is contained in:
@@ -0,0 +1,663 @@
|
||||
"""
|
||||
🦊 RaykoStudio - RS Text Overlay v3.10
|
||||
======================================
|
||||
Наложение текста на изображение с привязкой к маске.
|
||||
|
||||
v3.10 fix:
|
||||
• 🛠 Исправлена ошибка OpenCV в calculate_mask_rotation
|
||||
• 🛠 Правильная конвертация маски (torch → numpy → CV_8UC1)
|
||||
• 🎨 COLOR тип + JavaScript UI (550px ширина)
|
||||
• 🎚️ shadow_blur: FLOAT (0.0-1.0, step 0.05)
|
||||
• ❌ Убран blend_mode (всегда normal)
|
||||
|
||||
Структура файлов:
|
||||
custom_nodes/ComfyUI_RaykoStudio/
|
||||
├── Rayko_Text_Overlay.py ← этот файл
|
||||
├── web/
|
||||
│ └── extensions/
|
||||
│ └── rs_text_overlay.js ← JavaScript UI (550px)
|
||||
└── fonts/ ← папка со шрифтами
|
||||
|
||||
Автор: RaykoStudio
|
||||
Лицензия: MIT
|
||||
"""
|
||||
|
||||
import os
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageFilter
|
||||
import cv2
|
||||
import math
|
||||
import functools
|
||||
from typing import Dict, Tuple, List, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
|
||||
try:
|
||||
from comfy.utils import ProgressBar as ComfyProgressBar
|
||||
HAS_PBAR = True
|
||||
except ImportError:
|
||||
HAS_PBAR = False
|
||||
def ComfyProgressBar(total: int, desc: str = ""):
|
||||
class DummyPBar:
|
||||
def update(self, n: int = 1): pass
|
||||
return DummyPBar()
|
||||
|
||||
try:
|
||||
import folder_paths
|
||||
HAS_FOLDER_PATHS = True
|
||||
except ImportError:
|
||||
HAS_FOLDER_PATHS = False
|
||||
|
||||
print("\033[93m🦊\033[0m \033[93mRaykoStudio - RS Text Overlay \033[92mv3.10 LOADED\033[0m")
|
||||
|
||||
|
||||
class FontNotFoundError(Exception): pass
|
||||
class ColorParseError(Exception): pass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TextRenderConfig:
|
||||
font: ImageFont.ImageFont
|
||||
text_color: Tuple[int, int, int, int]
|
||||
outline_color: Tuple[int, int, int, int]
|
||||
outline_thickness: int
|
||||
letter_spacing: float
|
||||
line_spacing: float
|
||||
text_orientation: str
|
||||
enable_shadow: bool
|
||||
shadow_color: Tuple[int, int, int, int]
|
||||
shadow_offset: Tuple[int, int]
|
||||
shadow_blur: float
|
||||
text_opacity: int = 100
|
||||
|
||||
@property
|
||||
def has_outline(self) -> bool:
|
||||
return self.outline_thickness > 0
|
||||
|
||||
@property
|
||||
def has_shadow(self) -> bool:
|
||||
return self.enable_shadow and any(self.shadow_offset)
|
||||
|
||||
|
||||
class RS_TextOverlay:
|
||||
_font_cache: Dict[Tuple[str, int], ImageFont.ImageFont] = {}
|
||||
_color_cache: Dict[str, Tuple[int, int, int, int]] = {}
|
||||
_text_dim_cache: Dict[Tuple[str, str, float, float, float, int, str], Tuple[float, float, List[float], List[float]]] = {}
|
||||
_contour_cache: Dict[int, float] = {}
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
fonts_dir = os.path.join(current_dir, "fonts")
|
||||
|
||||
font_list = ["default"]
|
||||
if os.path.isdir(fonts_dir):
|
||||
for f in sorted(os.listdir(fonts_dir)):
|
||||
if f.lower().endswith(('.ttf', '.otf', '.ttc')):
|
||||
font_list.append(f)
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"mask": ("MASK",),
|
||||
"text": ("STRING", {
|
||||
"default": "Текст\nс переносом",
|
||||
"multiline": True,
|
||||
"tooltip": "Текст для отображения"
|
||||
}),
|
||||
"font_name": (font_list, {
|
||||
"default": font_list[0] if len(font_list) > 1 else "default",
|
||||
"tooltip": "Шрифт из папки fonts/"
|
||||
}),
|
||||
"text_color": ("COLOR", {
|
||||
"default": [1.0, 1.0, 1.0, 1.0],
|
||||
"tooltip": "Цвет текста"
|
||||
}),
|
||||
"outline_thickness": ("INT", {"default": 0, "min": 0, "max": 50, "tooltip": "Толщина обводки"}),
|
||||
"outline_color": ("COLOR", {
|
||||
"default": [0.3, 0.3, 0.3, 1.0],
|
||||
"tooltip": "Цвет обводки"
|
||||
}),
|
||||
"rotate_with_mask": ("BOOLEAN", {"default": True, "tooltip": "Поворачивать с маской"}),
|
||||
"text_opacity": ("FLOAT", {"default": 1.0, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
"min_font_size": ("INT", {"default": 8, "min": 1, "max": 500}),
|
||||
"max_font_size": ("INT", {"default": 500, "min": 10, "max": 2000}),
|
||||
"padding": ("INT", {"default": 5, "min": 0, "max": 200}),
|
||||
"vertical_align": (["top", "center", "bottom"], {"default": "center"}),
|
||||
"horizontal_align": (["left", "center", "right"], {"default": "center"}),
|
||||
"line_spacing": ("FLOAT", {"default": 1.0, "min": 0.5, "max": 3.0, "step": 0.1}),
|
||||
"letter_spacing": ("FLOAT", {"default": 0.0, "min": -20.0, "max": 100.0, "step": 0.5}),
|
||||
"text_orientation": (["horizontal", "vertical"], {"default": "horizontal"}),
|
||||
},
|
||||
"optional": {
|
||||
"enable_shadow": ("BOOLEAN", {"default": False}),
|
||||
"shadow_color": ("COLOR", {
|
||||
"default": [0.2, 0.2, 0.2, 1.0],
|
||||
"tooltip": "Цвет тени"
|
||||
}),
|
||||
"shadow_offset_x": ("INT", {"default": 2, "min": -50, "max": 50}),
|
||||
"shadow_offset_y": ("INT", {"default": 2, "min": -50, "max": 50}),
|
||||
"shadow_blur": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.05}),
|
||||
"shadow_opacity": ("FLOAT", {"default": 0.8, "min": 0.0, "max": 1.0, "step": 0.01}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
FUNCTION = "apply_text"
|
||||
CATEGORY = "🦊 RaykoStudio"
|
||||
|
||||
def __init__(self):
|
||||
self.debug = False
|
||||
|
||||
def log(self, message: str, level: int = 1):
|
||||
if self.debug or level <= 0:
|
||||
prefix = {0: "❌", 1: "ℹ️", 2: "🔍"}.get(level, "•")
|
||||
print(f"{prefix} RS_TextOverlay: {message}")
|
||||
|
||||
def get_font_path(self, font_name: str) -> Optional[str]:
|
||||
if font_name == "default":
|
||||
return None
|
||||
|
||||
strategies = []
|
||||
current_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
strategies.append(os.path.join(current_dir, "fonts", font_name))
|
||||
|
||||
if HAS_FOLDER_PATHS:
|
||||
try:
|
||||
font_dirs = folder_paths.get_folder_paths("fonts")
|
||||
if font_dirs:
|
||||
for font_dir in font_dirs:
|
||||
strategies.append(os.path.join(font_dir, font_name))
|
||||
except KeyError:
|
||||
self.log("Folder 'fonts' not registered in ComfyUI", level=2)
|
||||
except Exception as e:
|
||||
self.log(f"Error accessing folder_paths: {e}", level=1)
|
||||
|
||||
if os.path.isabs(font_name):
|
||||
strategies.append(font_name)
|
||||
|
||||
for path in strategies:
|
||||
try:
|
||||
if path and os.path.isfile(path) and path.lower().endswith(('.ttf', '.otf', '.ttc')):
|
||||
self.log(f"Font found: {path}", level=2)
|
||||
return path
|
||||
except (OSError, PermissionError) as e:
|
||||
self.log(f"Cannot access {path}: {e}", level=0)
|
||||
|
||||
return None
|
||||
|
||||
@functools.lru_cache(maxsize=32)
|
||||
def get_cached_font(self, font_path: str, size: int) -> ImageFont.ImageFont:
|
||||
cache_key = (font_path, size)
|
||||
if cache_key in self._font_cache:
|
||||
return self._font_cache[cache_key]
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, size)
|
||||
self._font_cache[cache_key] = font
|
||||
return font
|
||||
except Exception as e:
|
||||
raise FontNotFoundError(f"Не удалось загрузить шрифт: {e}")
|
||||
|
||||
def parse_color(self, color: Any, alpha: float = 1.0) -> Tuple[int, int, int, int]:
|
||||
if color is None:
|
||||
return (255, 255, 255, int(alpha * 255))
|
||||
|
||||
cache_key = (str(color), alpha)
|
||||
if cache_key in self._color_cache:
|
||||
return self._color_cache[cache_key]
|
||||
|
||||
try:
|
||||
if isinstance(color, (list, tuple)) and len(color) >= 3:
|
||||
r = int(min(1.0, max(0.0, float(color[0]))) * 255)
|
||||
g = int(min(1.0, max(0.0, float(color[1]))) * 255)
|
||||
b = int(min(1.0, max(0.0, float(color[2]))) * 255)
|
||||
a = int(min(1.0, max(0.0, float(color[3]))) * 255) if len(color) >= 4 else int(alpha * 255)
|
||||
result = (r, g, b, a)
|
||||
|
||||
elif isinstance(color, str):
|
||||
color_str = color.strip()
|
||||
|
||||
if color_str.startswith('#'):
|
||||
hex_val = color_str[1:]
|
||||
else:
|
||||
hex_val = color_str
|
||||
|
||||
if len(hex_val) == 3:
|
||||
r = int(hex_val[0] * 2, 16)
|
||||
g = int(hex_val[1] * 2, 16)
|
||||
b = int(hex_val[2] * 2, 16)
|
||||
a = int(alpha * 255)
|
||||
elif len(hex_val) == 6:
|
||||
r = int(hex_val[0:2], 16)
|
||||
g = int(hex_val[2:4], 16)
|
||||
b = int(hex_val[4:6], 16)
|
||||
a = int(alpha * 255)
|
||||
elif len(hex_val) == 8:
|
||||
r = int(hex_val[0:2], 16)
|
||||
g = int(hex_val[2:4], 16)
|
||||
b = int(hex_val[4:6], 16)
|
||||
a = int(hex_val[6:8], 16)
|
||||
else:
|
||||
r, g, b, a = 255, 255, 255, int(alpha * 255)
|
||||
|
||||
result = (r, g, b, a)
|
||||
else:
|
||||
result = (255, 255, 255, int(alpha * 255))
|
||||
|
||||
self._color_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Color parse error: {e}", level=0)
|
||||
return (255, 255, 255, int(alpha * 255))
|
||||
|
||||
@functools.lru_cache(maxsize=64)
|
||||
def calculate_text_dimensions(self, font_key: str, text: str, letter_spacing: float,
|
||||
line_spacing: float, outline_thickness: int,
|
||||
text_orientation: str) -> Tuple[float, float, List[float], List[float]]:
|
||||
cache_key = (font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation)
|
||||
if cache_key in self._text_dim_cache:
|
||||
return self._text_dim_cache[cache_key]
|
||||
|
||||
temp_img = Image.new("RGB", (1, 1))
|
||||
draw = ImageDraw.Draw(temp_img)
|
||||
|
||||
font_path, size_str = font_key.rsplit(':', 1)
|
||||
font = self.get_cached_font(font_path, int(size_str)) if font_path != "default" else ImageFont.load_default()
|
||||
|
||||
lines = text.split('\n') if text_orientation == "horizontal" else [text.replace('\n', '')]
|
||||
line_heights, line_widths = [], []
|
||||
|
||||
for line in lines:
|
||||
line = line.replace('\t', ' ')
|
||||
if not line:
|
||||
line_heights.append(0)
|
||||
line_widths.append(0)
|
||||
continue
|
||||
|
||||
if text_orientation == "horizontal":
|
||||
bbox = draw.textbbox((0, 0), line, font=font)
|
||||
char_count = len(line)
|
||||
line_width = draw.textlength(line, font=font) + letter_spacing * max(0, char_count - 1)
|
||||
line_height = (bbox[3] - bbox[1]) * line_spacing
|
||||
else:
|
||||
if line:
|
||||
char_bbox = draw.textbbox((0, 0), line[0], font=font)
|
||||
char_height = char_bbox[3] - char_bbox[1]
|
||||
line_width = draw.textlength(line[0], font=font)
|
||||
line_height = (char_height + letter_spacing) * len(line)
|
||||
else:
|
||||
line_width = line_height = 0
|
||||
|
||||
line_heights.append(line_height + outline_thickness * 2)
|
||||
line_widths.append(line_width + outline_thickness * 2)
|
||||
|
||||
result = (sum(line_heights), max(line_widths) if line_widths else 0, line_heights, line_widths)
|
||||
self._text_dim_cache[cache_key] = result
|
||||
return result
|
||||
|
||||
def text_fits(self, font_key: str, text: str, max_width: float, max_height: float,
|
||||
letter_spacing: float, line_spacing: float, outline_thickness: int,
|
||||
text_orientation: str) -> bool:
|
||||
total_height, max_line_width, _, _ = self.calculate_text_dimensions(
|
||||
font_key, text, letter_spacing, line_spacing, outline_thickness, text_orientation
|
||||
)
|
||||
return max_line_width <= max_width and total_height <= max_height
|
||||
|
||||
def find_optimal_font_size(self, font_path: Optional[str], text: str,
|
||||
max_width: float, max_height: float,
|
||||
min_size: int, max_size: int, config: TextRenderConfig,
|
||||
pbar: Optional[Any] = None) -> Tuple[ImageFont.ImageFont, int]:
|
||||
font_key = lambda size: f"{font_path or 'default'}:{size}"
|
||||
|
||||
if self.text_fits(font_key(max_size), text, max_width, max_height,
|
||||
config.letter_spacing, config.line_spacing,
|
||||
config.outline_thickness, config.text_orientation):
|
||||
return self.get_cached_font(font_path, max_size) if font_path else ImageFont.load_default(), max_size
|
||||
|
||||
low, high = min_size, max_size
|
||||
best_size, best_font = min_size, None
|
||||
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
if pbar: pbar.update(1)
|
||||
|
||||
try:
|
||||
font = self.get_cached_font(font_path, mid) if font_path else ImageFont.load_default()
|
||||
except FontNotFoundError:
|
||||
high = mid - 1
|
||||
continue
|
||||
|
||||
if self.text_fits(font_key(mid), text, max_width, max_height,
|
||||
config.letter_spacing, config.line_spacing,
|
||||
config.outline_thickness, config.text_orientation):
|
||||
best_font, best_size = font, mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
|
||||
if best_font is None:
|
||||
best_font = self.get_cached_font(font_path, min_size) if font_path else ImageFont.load_default()
|
||||
|
||||
return best_font, best_size
|
||||
|
||||
# ==================== Mask & Rotation (FIXED v3.10) ====================
|
||||
def _prepare_mask_for_opencv(self, mask_tensor: torch.Tensor) -> np.ndarray:
|
||||
"""
|
||||
🛠 v3.10 FIX: Правильная конвертация маски для OpenCV
|
||||
|
||||
Требуется формат: CV_8UC1 (single channel, 8-bit, uint8)
|
||||
"""
|
||||
# 1. Конвертируем в numpy
|
||||
mask_np = mask_tensor.cpu().numpy()
|
||||
|
||||
# 2. Убираем лишние измерения (если есть batch или channel)
|
||||
if mask_np.ndim == 3:
|
||||
# [H, W, C] или [C, H, W] → берём первый канал
|
||||
if mask_np.shape[0] in [1, 3, 4]:
|
||||
mask_np = mask_np[0] # [C, H, W]
|
||||
elif mask_np.shape[-1] in [1, 3, 4]:
|
||||
mask_np = mask_np[:, :, 0] # [H, W, C]
|
||||
elif mask_np.ndim == 4:
|
||||
# [B, H, W, C] или [B, C, H, W] → берём первый элемент
|
||||
mask_np = mask_np[0]
|
||||
if mask_np.ndim == 3:
|
||||
if mask_np.shape[0] in [1, 3, 4]:
|
||||
mask_np = mask_np[0]
|
||||
elif mask_np.shape[-1] in [1, 3, 4]:
|
||||
mask_np = mask_np[:, :, 0]
|
||||
|
||||
# 3. Нормализуем значения (если float 0-1 → uint8 0-255)
|
||||
if mask_np.dtype == np.float32 or mask_np.dtype == np.float64:
|
||||
mask_np = (mask_np * 255).astype(np.uint8)
|
||||
elif mask_np.dtype != np.uint8:
|
||||
mask_np = mask_np.astype(np.uint8)
|
||||
|
||||
# 4. Убеждаемся, что это 2D массив
|
||||
if mask_np.ndim != 2:
|
||||
self.log(f"Mask shape issue: {mask_np.shape}, attempting to fix", level=1)
|
||||
mask_np = mask_np.squeeze()
|
||||
if mask_np.ndim != 2:
|
||||
raise ValueError(f"Cannot convert mask to 2D: {mask_np.shape}")
|
||||
|
||||
# 5. Бинаризация (опционально, для лучших контуров)
|
||||
_, mask_np = cv2.threshold(mask_np, 127, 255, cv2.THRESH_BINARY)
|
||||
|
||||
return mask_np
|
||||
|
||||
def calculate_mask_rotation(self, mask_np: np.ndarray) -> float:
|
||||
"""
|
||||
Вычисляет угол поворота маски через minAreaRect.
|
||||
|
||||
🛠 v3.10 FIX: mask_np уже в правильном формате CV_8UC1
|
||||
"""
|
||||
mask_hash = hash(mask_np.tobytes())
|
||||
if mask_hash in self._contour_cache:
|
||||
return self._contour_cache[mask_hash]
|
||||
|
||||
try:
|
||||
# 🛠 FIX: mask_np уже в формате CV_8UC1 из _prepare_mask_for_opencv
|
||||
contours, _ = cv2.findContours(mask_np, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
|
||||
if not contours:
|
||||
return 0.0
|
||||
|
||||
contour = max(contours, key=cv2.contourArea)
|
||||
if cv2.contourArea(contour) < 10:
|
||||
return 0.0
|
||||
|
||||
rect = cv2.minAreaRect(contour)
|
||||
angle = rect[2]
|
||||
|
||||
if angle < -45:
|
||||
angle += 90
|
||||
elif angle > 45:
|
||||
angle -= 90
|
||||
|
||||
result = -angle
|
||||
self._contour_cache[mask_hash] = result
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Rotation calculation error: {e}", level=0)
|
||||
return 0.0
|
||||
|
||||
# ==================== Text Rendering ====================
|
||||
def _draw_single_line(self, draw: ImageDraw.Draw, position: Tuple[float, float],
|
||||
text: str, font: ImageFont.ImageFont, color: Tuple[int, int, int, int],
|
||||
letter_spacing: float, text_orientation: str) -> None:
|
||||
x, y = position
|
||||
|
||||
if text_orientation == "horizontal":
|
||||
current_x = x
|
||||
for char in text:
|
||||
draw.text((current_x, y), char, font=font, fill=color)
|
||||
current_x += draw.textlength(char, font=font) + letter_spacing
|
||||
else:
|
||||
current_y = y
|
||||
for char in text:
|
||||
draw.text((x, current_y), char, font=font, fill=color)
|
||||
bbox = draw.textbbox((x, current_y), char, font=font)
|
||||
current_y += (bbox[3] - bbox[1]) + letter_spacing
|
||||
|
||||
def render_text_layer(self, text: str, config: TextRenderConfig,
|
||||
container_size: Tuple[int, int]) -> Image.Image:
|
||||
text_layer = Image.new("RGBA", container_size, (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(text_layer)
|
||||
|
||||
total_h, max_w, line_heights, line_widths = self.calculate_text_dimensions(
|
||||
f"{config.font.path if hasattr(config.font, 'path') else 'default'}:{config.font.size}",
|
||||
text, config.letter_spacing, config.line_spacing,
|
||||
config.outline_thickness, config.text_orientation
|
||||
)
|
||||
|
||||
start_y = (container_size[1] - total_h) // 2
|
||||
current_y = start_y
|
||||
|
||||
lines = text.split('\n') if config.text_orientation == "horizontal" else [text.replace('\n', '')]
|
||||
|
||||
for line, line_w, line_h in zip(lines, line_widths, line_heights):
|
||||
if not line.strip():
|
||||
current_y += line_h
|
||||
continue
|
||||
|
||||
start_x = (container_size[0] - line_w) // 2
|
||||
|
||||
if config.has_outline:
|
||||
thickness = config.outline_thickness
|
||||
steps = max(2, thickness // 2)
|
||||
for dx in range(-thickness, thickness + 1, steps):
|
||||
for dy in range(-thickness, thickness + 1, steps):
|
||||
if dx == 0 and dy == 0: continue
|
||||
self._draw_single_line(draw, (start_x + dx, current_y + dy),
|
||||
line, config.font, config.outline_color,
|
||||
config.letter_spacing, config.text_orientation)
|
||||
|
||||
self._draw_single_line(draw, (start_x, current_y), line, config.font,
|
||||
config.text_color, config.letter_spacing, config.text_orientation)
|
||||
|
||||
current_y += line_h
|
||||
|
||||
if config.has_shadow:
|
||||
text_layer = self._apply_shadow_effect(text_layer, config)
|
||||
|
||||
return text_layer
|
||||
|
||||
def _apply_shadow_effect(self, text_layer: Image.Image, config: TextRenderConfig) -> Image.Image:
|
||||
alpha = text_layer.getchannel("A")
|
||||
shadow = Image.new("RGBA", text_layer.size, config.shadow_color)
|
||||
shadow.putalpha(alpha)
|
||||
|
||||
if config.shadow_blur > 0:
|
||||
blur_radius = int(config.shadow_blur * 20)
|
||||
if blur_radius > 0:
|
||||
shadow = shadow.filter(ImageFilter.GaussianBlur(blur_radius))
|
||||
|
||||
offset_x, offset_y = config.shadow_offset
|
||||
if offset_x or offset_y:
|
||||
shadow = shadow.transform(
|
||||
shadow.size, Image.AFFINE, (1, 0, offset_x, 0, 1, offset_y),
|
||||
Image.BICUBIC, fill=(0, 0, 0, 0)
|
||||
)
|
||||
|
||||
result = Image.new("RGBA", text_layer.size, (0, 0, 0, 0))
|
||||
result.paste(shadow, (0, 0), shadow)
|
||||
result.paste(text_layer, (0, 0), text_layer)
|
||||
return result
|
||||
|
||||
# ==================== Main Processing ====================
|
||||
def _process_single(self, image: torch.Tensor, mask: torch.Tensor, text: str,
|
||||
font_name: str, config: TextRenderConfig,
|
||||
padding: int, vertical_align: str, horizontal_align: str,
|
||||
rotate_with_mask: bool, min_font_size: int, max_font_size: int) -> torch.Tensor:
|
||||
|
||||
image_np = (image.cpu().numpy() * 255).astype(np.uint8)
|
||||
image_pil = Image.fromarray(image_np, 'RGB') if image_np.ndim == 3 else Image.fromarray(image_np[0], 'RGB')
|
||||
|
||||
# 🛠 v3.10 FIX: Правильная конвертация маски
|
||||
mask_np = self._prepare_mask_for_opencv(mask)
|
||||
mask_pil = Image.fromarray(mask_np, 'L')
|
||||
|
||||
bbox = mask_pil.getbbox()
|
||||
if not bbox:
|
||||
self.log("No bounding box in mask", level=1)
|
||||
return image
|
||||
|
||||
x1, y1, x2, y2 = bbox
|
||||
available_w = max(1, x2 - x1 - 2 * padding)
|
||||
available_h = max(1, y2 - y1 - 2 * padding)
|
||||
|
||||
font_path = self.get_font_path(font_name)
|
||||
|
||||
pbar = ComfyProgressBar(max_font_size - min_font_size, "Finding font size") if HAS_PBAR else None
|
||||
|
||||
config.font, actual_size = self.find_optimal_font_size(
|
||||
font_path, text, available_w, available_h, min_font_size, max_font_size, config, pbar
|
||||
)
|
||||
|
||||
angle = self.calculate_mask_rotation(mask_np) if rotate_with_mask else 0
|
||||
|
||||
diag = math.sqrt(available_w**2 + available_h**2)
|
||||
temp_size = int(diag * 1.2) + 50
|
||||
|
||||
text_layer = self.render_text_layer(text, config, (temp_size, temp_size))
|
||||
|
||||
if rotate_with_mask and abs(angle) > 0.1:
|
||||
text_layer = text_layer.rotate(angle, center=(temp_size//2, temp_size//2),
|
||||
resample=Image.BICUBIC, expand=False)
|
||||
|
||||
rot_bbox = text_layer.getbbox()
|
||||
if not rot_bbox:
|
||||
return image
|
||||
|
||||
rx1, ry1, rx2, ry2 = rot_bbox
|
||||
text_w, text_h = rx2 - rx1, ry2 - ry1
|
||||
|
||||
if rotate_with_mask:
|
||||
paste_x = int((x1 + x2 - text_w) / 2)
|
||||
paste_y = int((y1 + y2 - text_h) / 2)
|
||||
else:
|
||||
paste_x = {
|
||||
'left': x1 + padding,
|
||||
'right': x2 - text_w - padding,
|
||||
'center': int(x1 + (available_w - text_w) / 2 + padding)
|
||||
}[horizontal_align]
|
||||
paste_y = {
|
||||
'top': y1 + padding,
|
||||
'bottom': y2 - text_h - padding,
|
||||
'center': int(y1 + (available_h - text_h) / 2 + padding)
|
||||
}[vertical_align]
|
||||
|
||||
cropped = text_layer.crop(rot_bbox)
|
||||
image_rgba = image_pil.convert("RGBA")
|
||||
|
||||
overlay = Image.new("RGBA", image_rgba.size, (0, 0, 0, 0))
|
||||
overlay.paste(cropped, (paste_x, paste_y), cropped)
|
||||
|
||||
result = Image.alpha_composite(image_rgba, overlay)
|
||||
|
||||
result_np = np.array(result.convert("RGB")).astype(np.float32) / 255.0
|
||||
return torch.from_numpy(result_np).unsqueeze(0)
|
||||
|
||||
def apply_text(self,
|
||||
image: torch.Tensor,
|
||||
mask: torch.Tensor,
|
||||
text: str,
|
||||
font_name: str,
|
||||
text_color: Any,
|
||||
outline_thickness: int,
|
||||
outline_color: Any,
|
||||
rotate_with_mask: bool,
|
||||
text_opacity: float,
|
||||
min_font_size: int,
|
||||
max_font_size: int,
|
||||
padding: int,
|
||||
vertical_align: str,
|
||||
horizontal_align: str,
|
||||
line_spacing: float,
|
||||
letter_spacing: float,
|
||||
text_orientation: str,
|
||||
enable_shadow: bool = False,
|
||||
shadow_color: Any = None,
|
||||
shadow_offset_x: int = 2,
|
||||
shadow_offset_y: int = 2,
|
||||
shadow_blur: float = 0.0,
|
||||
shadow_opacity: float = 0.8,
|
||||
**kwargs) -> Tuple[torch.Tensor]:
|
||||
|
||||
try:
|
||||
if image is None or image.numel() == 0:
|
||||
raise ValueError("Input image is empty")
|
||||
if mask is None or mask.numel() == 0:
|
||||
raise ValueError("Input mask is empty")
|
||||
|
||||
config = TextRenderConfig(
|
||||
font=None,
|
||||
text_color=self.parse_color(text_color, text_opacity),
|
||||
outline_color=self.parse_color(outline_color, text_opacity),
|
||||
outline_thickness=outline_thickness,
|
||||
letter_spacing=letter_spacing,
|
||||
line_spacing=line_spacing,
|
||||
text_orientation=text_orientation,
|
||||
enable_shadow=enable_shadow,
|
||||
shadow_color=self.parse_color(shadow_color, shadow_opacity),
|
||||
shadow_offset=(shadow_offset_x, shadow_offset_y),
|
||||
shadow_blur=shadow_blur,
|
||||
text_opacity=int(text_opacity * 100)
|
||||
)
|
||||
|
||||
if image.ndim == 4:
|
||||
results = []
|
||||
for i in range(image.shape[0]):
|
||||
result = self._process_single(
|
||||
image[i], mask[i] if mask.ndim == 4 else mask, text, font_name,
|
||||
config, padding, vertical_align, horizontal_align,
|
||||
rotate_with_mask, min_font_size, max_font_size
|
||||
)
|
||||
results.append(result)
|
||||
return (torch.cat(results, dim=0),)
|
||||
else:
|
||||
result = self._process_single(
|
||||
image, mask, text, font_name, config, padding,
|
||||
vertical_align, horizontal_align, rotate_with_mask, min_font_size, max_font_size
|
||||
)
|
||||
return (result,)
|
||||
|
||||
except Exception as e:
|
||||
self.log(f"Error: {type(e).__name__}: {e}", level=0)
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return (image,)
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"RS_TextOverlay": RS_TextOverlay
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"RS_TextOverlay": "🦊 RS Text Overlay v3.10"
|
||||
}
|
||||
|
||||
__all__ = ["RS_TextOverlay", "NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
+4
-1
@@ -12,7 +12,10 @@
|
||||
"nodes": [
|
||||
"Rayko_Image_Text.py"
|
||||
"Rayko_Save_Image_Pair.py"
|
||||
"RS_Saturation_switch.py"
|
||||
"Rayko_Saturation.py"
|
||||
"Rayko_Spline_Mask.py"
|
||||
"Rayko_Models_Loader.py"
|
||||
"Rayko_Save_Image.py"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
import { app } from "../../scripts/app.js";
|
||||
|
||||
// ==================== КОНФИГУРАЦИЯ ====================
|
||||
const NODE_TYPE = "RS_TextOverlay";
|
||||
const NODE_WIDTH = 350;
|
||||
const NODE_HEIGHT = 750;
|
||||
const COLOR_INPUTS = ['text_color', 'outline_color', 'shadow_color'];
|
||||
// =====================================================
|
||||
|
||||
app.registerExtension({
|
||||
name: "RaykoStudio.TextOverlay",
|
||||
|
||||
async init() {
|
||||
const style = document.createElement('style');
|
||||
style.textContent = `
|
||||
/* Исправление чёрного текста на чёрном фоне */
|
||||
.litegraph .litemenu-entry.color_widget input[type="text"] {
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 0 2px #000000, 0 0 4px #000000;
|
||||
}
|
||||
|
||||
/* Цветная полоса виджета */
|
||||
.litegraph .litemenu-entry.color_widget .color_preview {
|
||||
border: 1px solid #ffffff;
|
||||
box-shadow: 0 0 3px rgba(0,0,0,0.5);
|
||||
}
|
||||
|
||||
/* Улучшенная читаемость для тёмных цветов */
|
||||
.litegraph .litemenu-entry input[type="text"][value="#000000"],
|
||||
.litegraph .litemenu-entry input[type="text"][value="#000"] {
|
||||
color: #ffffff !important;
|
||||
background: #333333 !important;
|
||||
}
|
||||
|
||||
/* Общий стиль для всех COLOR виджетов ноды */
|
||||
.node.RS_TextOverlay .widget_color input {
|
||||
color: #ffffff !important;
|
||||
text-shadow: 0 0 2px #000000;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Стили для светлых цветов */
|
||||
.litegraph .litemenu-entry input[type="text"][value="#FFFFFF"],
|
||||
.litegraph .litemenu-entry input[type="text"][value="#FFF"] {
|
||||
color: #000000 !important;
|
||||
text-shadow: none;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
console.log("🦊 Color widget styles applied");
|
||||
},
|
||||
|
||||
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
||||
if (nodeData.name !== NODE_TYPE) return;
|
||||
|
||||
const origOnNodeCreated = nodeType.prototype.onNodeCreated;
|
||||
|
||||
nodeType.prototype.onNodeCreated = function() {
|
||||
const result = origOnNodeCreated?.apply(this, arguments);
|
||||
|
||||
// 📐 Устанавливаем ширину ноды 400px
|
||||
this.size = [NODE_WIDTH, NODE_HEIGHT];
|
||||
this.setSize([NODE_WIDTH, this.size[1]]);
|
||||
|
||||
// 🎨 Настраиваем COLOR виджеты для лучшей читаемости
|
||||
if (this.widgets) {
|
||||
this.widgets.forEach((widget) => {
|
||||
if (COLOR_INPUTS.includes(widget.name)) {
|
||||
// Сохраняем оригинальный callback
|
||||
const origCallback = widget.callback;
|
||||
|
||||
// Переопределяем callback для обновления стилей
|
||||
widget.callback = function(value) {
|
||||
const result = origCallback?.apply(this, arguments);
|
||||
|
||||
// Обновляем стиль в зависимости от цвета
|
||||
if (widget.element) {
|
||||
const brightness = getColorBrightness(value);
|
||||
widget.element.style.color = brightness < 0.5 ? '#ffffff' : '#000000';
|
||||
widget.element.style.textShadow = brightness < 0.5
|
||||
? '0 0 2px #000000, 0 0 4px #000000'
|
||||
: 'none';
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
// Применяем стили сразу при создании
|
||||
setTimeout(() => {
|
||||
if (widget.element && widget.value) {
|
||||
const brightness = getColorBrightness(widget.value);
|
||||
widget.element.style.color = brightness < 0.5 ? '#ffffff' : '#000000';
|
||||
widget.element.style.textShadow = brightness < 0.5
|
||||
? '0 0 2px #000000, 0 0 4px #000000'
|
||||
: 'none';
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.setProperty("rs_text_overlay_version", "3.9");
|
||||
this.setProperty("node_width", NODE_WIDTH);
|
||||
|
||||
return result;
|
||||
};
|
||||
},
|
||||
|
||||
async loadedGraphNode(node, app) {
|
||||
if (node.type !== NODE_TYPE) return;
|
||||
|
||||
// 📐 Убеждаемся, что ширина установлена правильно
|
||||
if (node.size[0] < NODE_WIDTH) {
|
||||
node.setSize([NODE_WIDTH, node.size[1]]);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 🎨 Утилита для определения яркости цвета
|
||||
function getColorBrightness(color) {
|
||||
if (!color) return 1.0;
|
||||
|
||||
let r, g, b;
|
||||
|
||||
if (Array.isArray(color)) {
|
||||
// [R, G, B, A] формат (0-1)
|
||||
r = color[0] || 0;
|
||||
g = color[1] || 0;
|
||||
b = color[2] || 0;
|
||||
} else if (typeof color === 'string') {
|
||||
// HEX формат
|
||||
const hex = color.replace('#', '');
|
||||
if (hex.length === 3) {
|
||||
r = parseInt(hex[0] + hex[0], 16) / 255;
|
||||
g = parseInt(hex[1] + hex[1], 16) / 255;
|
||||
b = parseInt(hex[2] + hex[2], 16) / 255;
|
||||
} else if (hex.length >= 6) {
|
||||
r = parseInt(hex.substring(0, 2), 16) / 255;
|
||||
g = parseInt(hex.substring(2, 4), 16) / 255;
|
||||
b = parseInt(hex.substring(4, 6), 16) / 255;
|
||||
} else {
|
||||
return 1.0;
|
||||
}
|
||||
} else {
|
||||
return 1.0;
|
||||
}
|
||||
|
||||
// Формула яркости (ITU-R BT.709)
|
||||
return 0.2126 * r + 0.7152 * g + 0.0722 * b;
|
||||
}
|
||||
|
||||
console.log(`🦊 RS Text Overlay UI Extension registered for ${NODE_TYPE} (${NODE_WIDTH}px)`);
|
||||
Reference in New Issue
Block a user