Add files via upload
Adding initial concept nodes.
This commit is contained in:
+29
@@ -0,0 +1,29 @@
|
||||
# ComfyUI_ASCIINodes/__init__.py
|
||||
|
||||
# Import the node classes from their respective .py files
|
||||
# These files (ascii_art_node.py, ascii_animation_node.py, and color_ascii_animation_node.py)
|
||||
# should be in the same directory as this __init__.py file.
|
||||
|
||||
from .ascii_art_node import ASCIIArtGeneratorNode
|
||||
from .ascii_animation_node import ASCIIAnimationGeneratorNode
|
||||
from .color_ascii_animation_node import ColorASCIIAnimationGeneratorNode # New import
|
||||
|
||||
# A dictionary that ComfyUI uses to map node_class names to node_display_names
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"ASCIIArtGenerator": ASCIIArtGeneratorNode,
|
||||
"ASCIIAnimationGenerator": ASCIIAnimationGeneratorNode,
|
||||
"ColorASCIIAnimationGenerator": ColorASCIIAnimationGeneratorNode # New mapping
|
||||
}
|
||||
|
||||
# A dictionary that ComfyUI uses to map node_class names to their display names
|
||||
# This is what will appear in the ComfyUI menu
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"ASCIIArtGenerator": "ASCII Art Generator (Static)",
|
||||
"ASCIIAnimationGenerator": "ASCII Typing Animation Generator",
|
||||
"ColorASCIIAnimationGenerator": "Color ASCII Typing Animation" # New display name
|
||||
}
|
||||
|
||||
# Export the mappings
|
||||
__all__ = ['NODE_CLASS_MAPPINGS', 'NODE_DISPLAY_NAME_MAPPINGS']
|
||||
|
||||
print("ComfyUI_ASCIINodes: Loaded ASCII Art (Static), ASCII Typing Animation, and Color ASCII Typing Animation Nodes")
|
||||
@@ -0,0 +1,227 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageColor
|
||||
import os
|
||||
|
||||
class ASCIIAnimationGeneratorNode:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
default_charset = " .'`^\":;Il!i~_?[{1(|/fjxnvzXYJCLQ0Zmwqdkh*#M&8%B@$"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"char_width": ("INT", {"default": 80, "min": 10, "max": 1000, "step": 10, "display": "slider"}), # Max width reduced for performance
|
||||
"font_path": ("STRING", {"default": "cour.ttf", "multiline": False}),
|
||||
"font_size": ("INT", {"default": 15, "min": 5, "max": 100, "step": 1, "display": "slider"}),
|
||||
"ascii_charset": ("STRING", {"default": default_charset, "multiline": True}),
|
||||
"background_color": ("STRING", {"default": "#000000", "multiline": False}),
|
||||
"text_color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
||||
"invert_brightness_mapping": ("BOOLEAN", {"default": False}),
|
||||
"chars_per_frame": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1, "display":"slider"}), # New: control typing speed
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",) # Output is a batch of images
|
||||
RETURN_NAMES = ("animated_ascii_frames",)
|
||||
FUNCTION = "generate_ascii_animation"
|
||||
CATEGORY = "image/animation" # Or image/art
|
||||
|
||||
def tensor_to_pil(self, tensor_image: torch.Tensor) -> Image.Image:
|
||||
"""Converts a HWC PyTorch tensor (0-1 float) from the input batch to a PIL Image."""
|
||||
img_np = tensor_image.cpu().numpy()
|
||||
# Input tensor_image is expected to be HWC (from image[0])
|
||||
img_np = (img_np * 255).astype(np.uint8)
|
||||
|
||||
if img_np.shape[-1] == 1: # Grayscale
|
||||
return Image.fromarray(img_np.squeeze(-1), 'L')
|
||||
else: # RGB or RGBA
|
||||
return Image.fromarray(img_np, 'RGB' if img_np.shape[-1] == 3 else 'RGBA')
|
||||
|
||||
def pil_to_tensor_frame(self, pil_image: Image.Image) -> torch.Tensor:
|
||||
"""Converts a PIL Image to a HWC PyTorch tensor (0-1 float) for a single animation frame."""
|
||||
img_np = np.array(pil_image).astype(np.float32) / 255.0
|
||||
if img_np.ndim == 2: # Grayscale image, add channel dimension
|
||||
img_np = np.expand_dims(img_np, axis=2)
|
||||
|
||||
tensor_image = torch.from_numpy(img_np) # HWC
|
||||
return tensor_image
|
||||
|
||||
def generate_ascii_animation(self, image: torch.Tensor, char_width: int, font_path: str,
|
||||
font_size: int, ascii_charset: str, background_color: str,
|
||||
text_color: str, invert_brightness_mapping: bool,
|
||||
chars_per_frame: int):
|
||||
|
||||
# 0. Basic Input Validation
|
||||
if not ascii_charset:
|
||||
raise ValueError("ASCII charset cannot be empty.")
|
||||
if chars_per_frame < 1:
|
||||
chars_per_frame = 1
|
||||
|
||||
# 1. Convert Input Tensor to PIL Image (use the first image in the batch)
|
||||
if image.ndim == 3: # Should be (B,H,W,C), if (H,W,C) add batch
|
||||
image = image.unsqueeze(0)
|
||||
|
||||
input_pil_image = self.tensor_to_pil(image[0]) # image[0] is HWC
|
||||
original_width, original_height = input_pil_image.size
|
||||
|
||||
# 2. Convert PIL Image to Full ASCII Representation (list of strings)
|
||||
aspect_ratio = original_height / original_width
|
||||
# Character cell aspect ratio adjustment (0.5 means chars are ~twice as tall as wide)
|
||||
char_height = max(1, int(char_width * aspect_ratio * 0.5))
|
||||
|
||||
resized_image_for_ascii = input_pil_image.resize((char_width, char_height), Image.Resampling.LANCZOS)
|
||||
grayscale_image = resized_image_for_ascii.convert("L")
|
||||
|
||||
full_ascii_lines = []
|
||||
pixels = grayscale_image.load()
|
||||
for y_idx in range(char_height):
|
||||
line_of_text = ""
|
||||
for x_idx in range(char_width):
|
||||
brightness = pixels[x_idx, y_idx]
|
||||
if invert_brightness_mapping:
|
||||
brightness = 255 - brightness
|
||||
char_index = int((brightness / 255) * (len(ascii_charset) - 1))
|
||||
line_of_text += ascii_charset[char_index]
|
||||
full_ascii_lines.append(line_of_text)
|
||||
|
||||
if not any(full_ascii_lines): # If all lines are empty
|
||||
full_ascii_lines = [" "] # Ensure at least one space to avoid errors later
|
||||
|
||||
# 3. Prepare for Rendering (Font, Colors, Base Text Dimensions)
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except IOError:
|
||||
print(f"Warning: Font '{font_path}' not found. Falling back to default PIL font.")
|
||||
try: font = ImageFont.load_default(font_size=font_size)
|
||||
except AttributeError: font = ImageFont.load_default()
|
||||
except TypeError: font = ImageFont.load_default()
|
||||
|
||||
try:
|
||||
bg_color_rgb = ImageColor.getrgb(background_color)
|
||||
text_color_rgb = ImageColor.getrgb(text_color)
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid color string. Using black background/white text.")
|
||||
bg_color_rgb = (0,0,0)
|
||||
text_color_rgb = (255,255,255)
|
||||
|
||||
# Calculate dimensions of the text block if all ASCII art was rendered
|
||||
temp_draw = ImageDraw.Draw(Image.new("RGB", (1,1)))
|
||||
max_text_pixel_width = 0
|
||||
for line in full_ascii_lines:
|
||||
try: line_bbox = temp_draw.textbbox((0,0), line, font=font); width = line_bbox[2] - line_bbox[0]
|
||||
except AttributeError: width, _ = temp_draw.textsize(line, font=font) # Older Pillow
|
||||
max_text_pixel_width = max(max_text_pixel_width, width)
|
||||
|
||||
try: metrics_bbox = temp_draw.textbbox((0,0), "My", font=font); line_pixel_height = metrics_bbox[3] - metrics_bbox[1]
|
||||
except AttributeError: _, line_pixel_height = temp_draw.textsize("My", font=font) # Older Pillow
|
||||
|
||||
if line_pixel_height == 0 and font_size > 0: line_pixel_height = int(font_size * 1.2)
|
||||
if max_text_pixel_width == 0: max_text_pixel_width = char_width * font_size // 2
|
||||
if line_pixel_height == 0: line_pixel_height = font_size
|
||||
|
||||
text_render_width = max(1, max_text_pixel_width)
|
||||
text_render_height = max(1, len(full_ascii_lines) * line_pixel_height)
|
||||
|
||||
# 4. Generate Animation Frames
|
||||
output_frame_tensors = []
|
||||
total_chars_to_type = sum(len(line) for line in full_ascii_lines)
|
||||
chars_typed_so_far = 0
|
||||
|
||||
# Always generate at least one frame, even if it's just the background
|
||||
# or the first few characters if total_chars_to_type is small.
|
||||
|
||||
while chars_typed_so_far <= total_chars_to_type:
|
||||
current_frame_pil = Image.new("RGB", (text_render_width, text_render_height), bg_color_rgb)
|
||||
draw_frame = ImageDraw.Draw(current_frame_pil)
|
||||
|
||||
chars_drawn_this_frame_total = 0
|
||||
temp_chars_typed_count = 0 # Relative to start of full_ascii_lines
|
||||
|
||||
for line_idx, line_content in enumerate(full_ascii_lines):
|
||||
chars_to_draw_on_this_line = 0
|
||||
if temp_chars_typed_count < chars_typed_so_far:
|
||||
remaining_to_type_on_line = chars_typed_so_far - temp_chars_typed_count
|
||||
chars_to_draw_on_this_line = min(len(line_content), remaining_to_type_on_line)
|
||||
|
||||
if chars_to_draw_on_this_line > 0:
|
||||
draw_frame.text((0, line_idx * line_pixel_height),
|
||||
line_content[:chars_to_draw_on_this_line],
|
||||
font=font, fill=text_color_rgb)
|
||||
|
||||
temp_chars_typed_count += len(line_content)
|
||||
if temp_chars_typed_count >= chars_typed_so_far:
|
||||
break # Stop processing lines if all typed characters for this frame are drawn
|
||||
|
||||
# Resize the rendered text frame to original input dimensions
|
||||
final_frame_pil = current_frame_pil.resize((original_width, original_height), Image.Resampling.LANCZOS)
|
||||
output_frame_tensors.append(self.pil_to_tensor_frame(final_frame_pil))
|
||||
|
||||
if chars_typed_so_far >= total_chars_to_type:
|
||||
break # Animation complete
|
||||
|
||||
chars_typed_so_far += chars_per_frame
|
||||
if chars_typed_so_far > total_chars_to_type and (chars_typed_so_far - chars_per_frame) < total_chars_to_type :
|
||||
chars_typed_so_far = total_chars_to_type # Ensure the last frame shows everything
|
||||
|
||||
# 5. Batch and Return
|
||||
if not output_frame_tensors: # Should not happen with the new loop logic, but as a fallback
|
||||
fallback_pil = Image.new("RGB", (original_width, original_height), bg_color_rgb)
|
||||
output_frame_tensors.append(self.pil_to_tensor_frame(fallback_pil))
|
||||
|
||||
batched_output_tensor = torch.stack(output_frame_tensors, dim=0) # (num_frames, H, W, C)
|
||||
return (batched_output_tensor,)
|
||||
|
||||
# For testing the node independently (optional)
|
||||
if __name__ == '__main__':
|
||||
print("Testing ASCIIAnimationGeneratorNode locally...")
|
||||
node = ASCIIAnimationGeneratorNode()
|
||||
|
||||
# Create a dummy input PIL image for testing
|
||||
test_input_pil = Image.new('RGB', (100, 75), color = 'darkcyan') # W, H
|
||||
# Convert to ComfyUI-like tensor (B, H, W, C)
|
||||
test_input_np = np.array(test_input_pil).astype(np.float32) / 255.0
|
||||
dummy_tensor_bhwc = torch.from_numpy(test_input_np).unsqueeze(0)
|
||||
|
||||
print(f"Input tensor shape for test: {dummy_tensor_bhwc.shape}")
|
||||
|
||||
try:
|
||||
output_batched_tensor_tuple = node.generate_ascii_animation(
|
||||
image=dummy_tensor_bhwc,
|
||||
char_width=40, # Smaller for quicker test
|
||||
font_path="cour.ttf",
|
||||
font_size=10,
|
||||
ascii_charset=" .:oO0@",
|
||||
background_color="#202020",
|
||||
text_color="#33FF33",
|
||||
invert_brightness_mapping=False,
|
||||
chars_per_frame=5 # Type 5 characters per frame
|
||||
)
|
||||
output_batched_tensor = output_batched_tensor_tuple[0]
|
||||
print(f"Output batched tensor shape: {output_batched_tensor.shape}") # (num_frames, H, W, C)
|
||||
|
||||
num_frames = output_batched_tensor.shape[0]
|
||||
print(f"Generated {num_frames} frames.")
|
||||
|
||||
# To save a few frames for visual inspection (e.g., first, middle, last):
|
||||
if num_frames > 0:
|
||||
indices_to_save = [0]
|
||||
if num_frames > 1: indices_to_save.append(num_frames // 2)
|
||||
if num_frames > 2: indices_to_save.append(num_frames - 1)
|
||||
indices_to_save = sorted(list(set(indices_to_save))) # Unique sorted
|
||||
|
||||
for i, frame_idx in enumerate(indices_to_save):
|
||||
if frame_idx < num_frames:
|
||||
frame_tensor_hwc = output_batched_tensor[frame_idx] # H, W, C
|
||||
# Need a tensor_to_pil that handles HWC directly for saving
|
||||
frame_pil = node.tensor_to_pil(frame_tensor_hwc) # Re-use existing one
|
||||
frame_pil.save(f"test_animation_frame_{i+1}_idx{frame_idx}.png")
|
||||
print(f"Saved test_animation_frame_{i+1}_idx{frame_idx}.png")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during local test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,199 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageColor
|
||||
import os
|
||||
|
||||
class ASCIIArtGeneratorNode:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
default_charset = " .'`^\":;Il!i~_?[{1(|/fjxnvzXYJCLQ0Zmwqdkh*#M&8%B@$"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"char_width": ("INT", {"default": 100, "min": 10, "max": 2000, "step": 10, "display": "slider"}),
|
||||
"font_path": ("STRING", {"default": "cour.ttf", "multiline": False}),
|
||||
"font_size": ("INT", {"default": 15, "min": 5, "max": 100, "step": 1, "display": "slider"}),
|
||||
"ascii_charset": ("STRING", {"default": default_charset, "multiline": True}),
|
||||
"background_color": ("STRING", {"default": "#000000", "multiline": False}),
|
||||
"text_color": ("STRING", {"default": "#FFFFFF", "multiline": False}),
|
||||
"invert_brightness_mapping": ("BOOLEAN", {"default": False}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("ascii_image",)
|
||||
FUNCTION = "generate_ascii_art"
|
||||
CATEGORY = "image/art"
|
||||
|
||||
def generate_ascii_art(self, image: torch.Tensor, char_width: int, font_path: str,
|
||||
font_size: int, ascii_charset: str, background_color: str,
|
||||
text_color: str, invert_brightness_mapping: bool):
|
||||
|
||||
# 0. Basic Input Validation
|
||||
if not ascii_charset:
|
||||
raise ValueError("ASCII charset cannot be empty.")
|
||||
|
||||
# 1. Convert Tensor to PIL Image
|
||||
if image.ndim == 3:
|
||||
image = image.unsqueeze(0)
|
||||
|
||||
input_pil_image = self.tensor_to_pil(image[0])
|
||||
original_width, original_height = input_pil_image.size # Store original dimensions
|
||||
|
||||
# 2. Convert PIL Image to ASCII representation
|
||||
aspect_ratio = original_height / original_width
|
||||
# Ensure char_height is at least 1, even if aspect_ratio or char_width is small
|
||||
char_height = max(1, int(char_width * aspect_ratio * 0.5)) # Adjusted for typical char aspect ratio
|
||||
|
||||
resized_image_for_ascii = input_pil_image.resize((char_width, char_height), Image.Resampling.LANCZOS)
|
||||
grayscale_image = resized_image_for_ascii.convert("L")
|
||||
|
||||
ascii_lines = []
|
||||
pixels = grayscale_image.load()
|
||||
|
||||
for y_idx in range(char_height):
|
||||
line_of_text = ""
|
||||
for x_idx in range(char_width):
|
||||
brightness = pixels[x_idx, y_idx]
|
||||
if invert_brightness_mapping:
|
||||
brightness = 255 - brightness
|
||||
|
||||
char_index = int((brightness / 255) * (len(ascii_charset) - 1))
|
||||
line_of_text += ascii_charset[char_index]
|
||||
ascii_lines.append(line_of_text)
|
||||
|
||||
# 3. Render ASCII Text to a New Image (at its "natural" text size first)
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except IOError:
|
||||
print(f"Warning: Font '{font_path}' not found. Falling back to default PIL font.")
|
||||
try:
|
||||
font = ImageFont.load_default(font_size=font_size)
|
||||
except AttributeError: # Older Pillow
|
||||
font = ImageFont.load_default()
|
||||
except TypeError: # If font_size is not accepted by load_default
|
||||
font = ImageFont.load_default()
|
||||
|
||||
|
||||
temp_draw = ImageDraw.Draw(Image.new("RGB", (1,1)))
|
||||
|
||||
max_line_pixel_width = 0
|
||||
if ascii_lines:
|
||||
for line in ascii_lines:
|
||||
# Use textbbox for more accurate width, available in newer Pillow versions
|
||||
try:
|
||||
line_bbox = temp_draw.textbbox((0,0), line, font=font)
|
||||
max_line_pixel_width = max(max_line_pixel_width, line_bbox[2] - line_bbox[0])
|
||||
except AttributeError: # Fallback for older Pillow using textsize
|
||||
line_width, _ = temp_draw.textsize(line, font=font)
|
||||
max_line_pixel_width = max(max_line_pixel_width, line_width)
|
||||
|
||||
# Estimate line height
|
||||
try:
|
||||
# Using textbbox for a character with ascenders/descenders
|
||||
char_metrics_bbox = temp_draw.textbbox((0,0), "My", font=font)
|
||||
line_pixel_height = char_metrics_bbox[3] - char_metrics_bbox[1]
|
||||
except AttributeError: # Fallback for older Pillow
|
||||
_, line_pixel_height_fallback = temp_draw.textsize("My", font=font)
|
||||
line_pixel_height = line_pixel_height_fallback
|
||||
|
||||
if line_pixel_height == 0 and font_size > 0 :
|
||||
line_pixel_height = int(font_size * 1.2)
|
||||
|
||||
if max_line_pixel_width == 0: max_line_pixel_width = char_width * font_size // 2
|
||||
if line_pixel_height == 0: line_pixel_height = font_size
|
||||
|
||||
text_render_width = max(1, max_line_pixel_width)
|
||||
text_render_height = max(1, len(ascii_lines) * line_pixel_height)
|
||||
|
||||
try:
|
||||
bg_color_rgb = ImageColor.getrgb(background_color)
|
||||
text_color_rgb = ImageColor.getrgb(text_color)
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid color string. Using black background and white text.")
|
||||
bg_color_rgb = (0,0,0)
|
||||
text_color_rgb = (255,255,255)
|
||||
|
||||
# Create the initial rendered image based on text dimensions
|
||||
rendered_text_image = Image.new("RGB", (text_render_width, text_render_height), bg_color_rgb)
|
||||
draw = ImageDraw.Draw(rendered_text_image)
|
||||
|
||||
current_y = 0
|
||||
for line in ascii_lines:
|
||||
draw.text((0, current_y), line, font=font, fill=text_color_rgb)
|
||||
current_y += line_pixel_height
|
||||
|
||||
# 4. Resize the rendered ASCII art to match the original input image dimensions
|
||||
# This is the key change to match input image size.
|
||||
final_output_image = rendered_text_image.resize((original_width, original_height), Image.Resampling.LANCZOS)
|
||||
|
||||
# 5. Convert Final PIL Image back to Tensor
|
||||
output_tensor = self.pil_to_tensor(final_output_image)
|
||||
|
||||
return (output_tensor,)
|
||||
|
||||
def tensor_to_pil(self, tensor_image: torch.Tensor) -> Image.Image:
|
||||
img_np = tensor_image.cpu().numpy()
|
||||
if img_np.ndim == 3 and img_np.shape[0] in [1, 3, 4]:
|
||||
img_np = np.transpose(img_np, (1, 2, 0))
|
||||
img_np = (img_np * 255).astype(np.uint8)
|
||||
if img_np.shape[-1] == 1:
|
||||
return Image.fromarray(img_np.squeeze(-1), 'L')
|
||||
else:
|
||||
return Image.fromarray(img_np, 'RGB' if img_np.shape[-1] == 3 else 'RGBA')
|
||||
|
||||
def pil_to_tensor(self, pil_image: Image.Image) -> torch.Tensor:
|
||||
img_np = np.array(pil_image).astype(np.float32) / 255.0
|
||||
if img_np.ndim == 2:
|
||||
img_np = np.expand_dims(img_np, axis=2)
|
||||
tensor_image = torch.from_numpy(img_np)
|
||||
return tensor_image.unsqueeze(0) # Add batch dimension
|
||||
|
||||
# For testing the node independently (optional)
|
||||
if __name__ == '__main__':
|
||||
print("Testing ASCIIArtGeneratorNode locally...")
|
||||
node = ASCIIArtGeneratorNode()
|
||||
|
||||
# Create a dummy input PIL image for testing
|
||||
test_input_pil = Image.new('RGB', (200, 150), color = 'blue') # W, H
|
||||
# Convert to tensor (B, H, W, C)
|
||||
test_input_np = np.array(test_input_pil).astype(np.float32) / 255.0
|
||||
dummy_tensor_bhwc = torch.from_numpy(test_input_np).unsqueeze(0)
|
||||
|
||||
print(f"Input tensor shape for test: {dummy_tensor_bhwc.shape}")
|
||||
|
||||
try:
|
||||
output_image_tensor_tuple = node.generate_ascii_art(
|
||||
image=dummy_tensor_bhwc,
|
||||
char_width=80,
|
||||
font_path="cour.ttf",
|
||||
font_size=10,
|
||||
ascii_charset=" .:-=+*#%@",
|
||||
background_color="#101010",
|
||||
text_color="#00FF00",
|
||||
invert_brightness_mapping=False
|
||||
)
|
||||
output_image_tensor = output_image_tensor_tuple[0]
|
||||
print(f"Output tensor shape: {output_image_tensor.shape}")
|
||||
|
||||
# Check if output dimensions match input dimensions
|
||||
# Input was (1, 150, 200, 3) -> H=150, W=200
|
||||
# Output should be (1, 150, 200, 3)
|
||||
if output_image_tensor.shape[1] == 150 and output_image_tensor.shape[2] == 200:
|
||||
print("SUCCESS: Output dimensions match input dimensions.")
|
||||
else:
|
||||
print(f"FAILURE: Output dimensions {output_image_tensor.shape[1]}x{output_image_tensor.shape[2]} "
|
||||
f"do not match input 150x200.")
|
||||
|
||||
output_pil_image = node.tensor_to_pil(output_image_tensor[0])
|
||||
output_pil_image.save("test_ascii_output_same_size.png")
|
||||
print("Saved test_ascii_output_same_size.png")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during local test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
@@ -0,0 +1,256 @@
|
||||
import torch
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont, ImageColor
|
||||
import os
|
||||
|
||||
class ColorASCIIAnimationGeneratorNode:
|
||||
def __init__(self):
|
||||
pass
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(s):
|
||||
default_charset = " .'`^\":;Il!i~_?[{1(|/fjxnvzXYJCLQ0Zmwqdkh*#M&8%B@$"
|
||||
|
||||
return {
|
||||
"required": {
|
||||
"image": ("IMAGE",),
|
||||
"char_width": ("INT", {"default": 80, "min": 10, "max": 1000, "step": 10, "display": "slider"}),
|
||||
"font_path": ("STRING", {"default": "cour.ttf", "multiline": False}),
|
||||
"font_size": ("INT", {"default": 15, "min": 5, "max": 100, "step": 1, "display": "slider"}),
|
||||
"ascii_charset": ("STRING", {"default": default_charset, "multiline": True}),
|
||||
"background_color": ("STRING", {"default": "#000000", "multiline": False}),
|
||||
# text_color is removed as character color comes from the image
|
||||
"invert_brightness_mapping": ("BOOLEAN", {"default": False}),
|
||||
"chars_per_frame": ("INT", {"default": 1, "min": 1, "max": 100, "step": 1, "display":"slider"}),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",) # Output is a batch of images
|
||||
RETURN_NAMES = ("colored_animated_ascii_frames",)
|
||||
FUNCTION = "generate_color_ascii_animation"
|
||||
CATEGORY = "image/animation"
|
||||
|
||||
def tensor_to_pil(self, tensor_image: torch.Tensor) -> Image.Image:
|
||||
"""Converts a HWC PyTorch tensor (0-1 float) from the input batch to a PIL Image."""
|
||||
img_np = tensor_image.cpu().numpy()
|
||||
img_np = (img_np * 255).astype(np.uint8)
|
||||
if img_np.shape[-1] == 1:
|
||||
return Image.fromarray(img_np.squeeze(-1), 'L').convert('RGB') # Ensure RGB for color sampling
|
||||
else:
|
||||
return Image.fromarray(img_np, 'RGB' if img_np.shape[-1] == 3 else 'RGBA').convert('RGB')
|
||||
|
||||
|
||||
def pil_to_tensor_frame(self, pil_image: Image.Image) -> torch.Tensor:
|
||||
"""Converts a PIL Image to a HWC PyTorch tensor (0-1 float) for a single animation frame."""
|
||||
img_np = np.array(pil_image.convert("RGB")).astype(np.float32) / 255.0 # Ensure RGB
|
||||
if img_np.ndim == 2:
|
||||
img_np = np.expand_dims(img_np, axis=2)
|
||||
img_np = np.repeat(img_np, 3, axis=2) # Convert grayscale to RGB by repeating channel
|
||||
|
||||
tensor_image = torch.from_numpy(img_np)
|
||||
return tensor_image
|
||||
|
||||
def generate_color_ascii_animation(self, image: torch.Tensor, char_width: int, font_path: str,
|
||||
font_size: int, ascii_charset: str, background_color: str,
|
||||
invert_brightness_mapping: bool, chars_per_frame: int):
|
||||
|
||||
if not ascii_charset:
|
||||
raise ValueError("ASCII charset cannot be empty.")
|
||||
if chars_per_frame < 1:
|
||||
chars_per_frame = 1
|
||||
|
||||
if image.ndim == 3:
|
||||
image = image.unsqueeze(0)
|
||||
|
||||
input_pil_image_rgb = self.tensor_to_pil(image[0]) # Ensure it's RGB
|
||||
original_width, original_height = input_pil_image_rgb.size
|
||||
|
||||
aspect_ratio = original_height / original_width
|
||||
char_height = max(1, int(char_width * aspect_ratio * 0.5))
|
||||
|
||||
# Image for brightness mapping (grayscale)
|
||||
resized_image_for_brightness = input_pil_image_rgb.resize((char_width, char_height), Image.Resampling.LANCZOS)
|
||||
grayscale_image = resized_image_for_brightness.convert("L")
|
||||
|
||||
# Image for color sampling (RGB, same dimensions as grayscale)
|
||||
# This ensures direct correspondence between character position and color sample
|
||||
color_sample_image = resized_image_for_brightness # Already RGB and resized
|
||||
|
||||
full_ascii_map = [] # Will store list of lines, where each line is list of (char, (r,g,b))
|
||||
|
||||
gray_pixels = grayscale_image.load()
|
||||
color_pixels = color_sample_image.load()
|
||||
|
||||
for y_idx in range(char_height):
|
||||
line_map = []
|
||||
for x_idx in range(char_width):
|
||||
brightness = gray_pixels[x_idx, y_idx]
|
||||
if invert_brightness_mapping:
|
||||
brightness = 255 - brightness
|
||||
|
||||
char_index = int((brightness / 255) * (len(ascii_charset) - 1))
|
||||
char_to_draw = ascii_charset[char_index]
|
||||
|
||||
# Sample color from the color_sample_image at the same (x,y)
|
||||
sampled_color = color_pixels[x_idx, y_idx] # This will be an (R, G, B) tuple
|
||||
line_map.append((char_to_draw, sampled_color))
|
||||
full_ascii_map.append(line_map)
|
||||
|
||||
if not any(full_ascii_map):
|
||||
# Ensure at least one space with a default color if map is empty
|
||||
full_ascii_map = [[(' ', (128,128,128))]]
|
||||
|
||||
|
||||
try:
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
except IOError:
|
||||
print(f"Warning: Font '{font_path}' not found. Falling back to default PIL font.")
|
||||
try: font = ImageFont.load_default(font_size=font_size)
|
||||
except AttributeError: font = ImageFont.load_default()
|
||||
except TypeError: font = ImageFont.load_default()
|
||||
|
||||
try:
|
||||
bg_color_rgb = ImageColor.getrgb(background_color)
|
||||
except ValueError:
|
||||
print(f"Warning: Invalid background color. Using black.")
|
||||
bg_color_rgb = (0,0,0)
|
||||
|
||||
temp_draw = ImageDraw.Draw(Image.new("RGB", (1,1)))
|
||||
max_text_pixel_width = 0
|
||||
|
||||
# Estimate max width by rendering a line of 'M's (a wide character)
|
||||
# This is a simplification; true max width depends on the actual characters.
|
||||
# A more accurate way would be to iterate through full_ascii_map and sum widths,
|
||||
# but that's more complex if characters are not monospaced.
|
||||
# For monospaced fonts, this is simpler: char_width * (width of one char).
|
||||
|
||||
# Using textbbox for a single character to estimate width/height
|
||||
try:
|
||||
char_bbox = temp_draw.textbbox((0,0), "M", font=font) # 'M' is often a wide char
|
||||
single_char_width = char_bbox[2] - char_bbox[0]
|
||||
line_pixel_height = char_bbox[3] - char_bbox[1]
|
||||
except AttributeError: # Fallback for older Pillow
|
||||
single_char_width, line_pixel_height = temp_draw.textsize("M", font=font)
|
||||
|
||||
if single_char_width == 0: single_char_width = font_size // 2 # Rough fallback
|
||||
if line_pixel_height == 0: line_pixel_height = font_size # Rough fallback
|
||||
|
||||
max_text_pixel_width = char_width * single_char_width
|
||||
|
||||
text_render_width = max(1, max_text_pixel_width)
|
||||
text_render_height = max(1, len(full_ascii_map) * line_pixel_height)
|
||||
|
||||
output_frame_tensors = []
|
||||
total_chars_to_type = sum(len(line) for line in full_ascii_map)
|
||||
chars_typed_so_far = 0
|
||||
|
||||
while chars_typed_so_far <= total_chars_to_type:
|
||||
current_frame_pil = Image.new("RGB", (text_render_width, text_render_height), bg_color_rgb)
|
||||
draw_frame = ImageDraw.Draw(current_frame_pil)
|
||||
|
||||
temp_chars_typed_count = 0
|
||||
|
||||
for line_idx, line_content_map in enumerate(full_ascii_map):
|
||||
current_x_offset = 0
|
||||
for char_idx, (char_to_draw, char_color) in enumerate(line_content_map):
|
||||
if temp_chars_typed_count < chars_typed_so_far:
|
||||
draw_frame.text((current_x_offset, line_idx * line_pixel_height),
|
||||
char_to_draw,
|
||||
font=font,
|
||||
fill=char_color) # Use individual char_color
|
||||
|
||||
# Get width of current character to advance x_offset
|
||||
try:
|
||||
bbox = draw_frame.textbbox((0,0), char_to_draw, font=font)
|
||||
char_pixel_width = bbox[2] - bbox[0]
|
||||
except AttributeError:
|
||||
char_pixel_width, _ = draw_frame.textsize(char_to_draw, font=font)
|
||||
|
||||
current_x_offset += char_pixel_width
|
||||
temp_chars_typed_count += 1
|
||||
|
||||
if temp_chars_typed_count >= chars_typed_so_far:
|
||||
break # Break from inner loop (chars in line)
|
||||
if temp_chars_typed_count >= chars_typed_so_far:
|
||||
break # Break from outer loop (lines)
|
||||
|
||||
final_frame_pil = current_frame_pil.resize((original_width, original_height), Image.Resampling.LANCZOS)
|
||||
output_frame_tensors.append(self.pil_to_tensor_frame(final_frame_pil))
|
||||
|
||||
if chars_typed_so_far >= total_chars_to_type:
|
||||
break
|
||||
|
||||
chars_typed_so_far += chars_per_frame
|
||||
if chars_typed_so_far > total_chars_to_type and (chars_typed_so_far - chars_per_frame) < total_chars_to_type :
|
||||
chars_typed_so_far = total_chars_to_type
|
||||
|
||||
if not output_frame_tensors:
|
||||
fallback_pil = Image.new("RGB", (original_width, original_height), bg_color_rgb)
|
||||
output_frame_tensors.append(self.pil_to_tensor_frame(fallback_pil))
|
||||
|
||||
batched_output_tensor = torch.stack(output_frame_tensors, dim=0)
|
||||
return (batched_output_tensor,)
|
||||
|
||||
# For testing the node independently (optional)
|
||||
if __name__ == '__main__':
|
||||
print("Testing ColorASCIIAnimationGeneratorNode locally...")
|
||||
node = ColorASCIIAnimationGeneratorNode()
|
||||
|
||||
# Create a dummy input PIL image with varied colors
|
||||
test_w, test_h = 120, 90
|
||||
gradient_img = Image.new("RGB", (test_w, test_h))
|
||||
gradient_draw = ImageDraw.Draw(gradient_img)
|
||||
for i in range(test_w):
|
||||
r = int((i / test_w) * 255)
|
||||
for j in range(test_h):
|
||||
g = int((j / test_h) * 255)
|
||||
b = 128
|
||||
gradient_draw.point((i,j), fill=(r,g,b))
|
||||
|
||||
test_input_np = np.array(gradient_img).astype(np.float32) / 255.0
|
||||
dummy_tensor_bhwc = torch.from_numpy(test_input_np).unsqueeze(0)
|
||||
|
||||
print(f"Input tensor shape for test: {dummy_tensor_bhwc.shape}")
|
||||
|
||||
try:
|
||||
output_batched_tensor_tuple = node.generate_color_ascii_animation(
|
||||
image=dummy_tensor_bhwc,
|
||||
char_width=60,
|
||||
font_path="cour.ttf",
|
||||
font_size=12,
|
||||
ascii_charset=" .:oO0@",
|
||||
background_color="#111111",
|
||||
invert_brightness_mapping=False,
|
||||
chars_per_frame=10
|
||||
)
|
||||
output_batched_tensor = output_batched_tensor_tuple[0]
|
||||
print(f"Output batched tensor shape: {output_batched_tensor.shape}")
|
||||
|
||||
num_frames = output_batched_tensor.shape[0]
|
||||
print(f"Generated {num_frames} frames.")
|
||||
|
||||
if num_frames > 0:
|
||||
indices_to_save = [0]
|
||||
if num_frames > 1: indices_to_save.append(num_frames // 2)
|
||||
if num_frames > 2: indices_to_save.append(num_frames - 1)
|
||||
indices_to_save = sorted(list(set(indices_to_save)))
|
||||
|
||||
for i, frame_idx in enumerate(indices_to_save):
|
||||
if frame_idx < num_frames:
|
||||
frame_tensor_hwc = output_batched_tensor[frame_idx]
|
||||
# Need a tensor_to_pil that handles HWC directly for saving
|
||||
# The existing tensor_to_pil in the class expects a batched tensor's HWC slice
|
||||
# So we make a dummy batch for it or adapt
|
||||
pil_converter = node.tensor_to_pil # This expects HWC from image[0]
|
||||
|
||||
# For saving, we need to convert HWC tensor to PIL
|
||||
frame_np = (frame_tensor_hwc.cpu().numpy() * 255).astype(np.uint8)
|
||||
frame_pil = Image.fromarray(frame_np, 'RGB')
|
||||
|
||||
frame_pil.save(f"test_color_animation_frame_{i+1}_idx{frame_idx}.png")
|
||||
print(f"Saved test_color_animation_frame_{i+1}_idx{frame_idx}.png")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error during local test: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
Reference in New Issue
Block a user