From 51454b06974c337f027e4eb2dae4e6b9079cfdc8 Mon Sep 17 00:00:00 2001 From: LamEmil Date: Sat, 17 May 2025 19:19:46 +0100 Subject: [PATCH] Add files via upload Adding initial concept nodes. --- __init__.py | 29 ++++ ascii_animation_node.py | 227 ++++++++++++++++++++++++++++++ ascii_art_node.py | 199 ++++++++++++++++++++++++++ color_ascii_animation_node.py | 256 ++++++++++++++++++++++++++++++++++ 4 files changed, 711 insertions(+) create mode 100644 __init__.py create mode 100644 ascii_animation_node.py create mode 100644 ascii_art_node.py create mode 100644 color_ascii_animation_node.py diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..78c6c34 --- /dev/null +++ b/__init__.py @@ -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") diff --git a/ascii_animation_node.py b/ascii_animation_node.py new file mode 100644 index 0000000..78c32c9 --- /dev/null +++ b/ascii_animation_node.py @@ -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() diff --git a/ascii_art_node.py b/ascii_art_node.py new file mode 100644 index 0000000..c0890a9 --- /dev/null +++ b/ascii_art_node.py @@ -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() diff --git a/color_ascii_animation_node.py b/color_ascii_animation_node.py new file mode 100644 index 0000000..75c9667 --- /dev/null +++ b/color_ascii_animation_node.py @@ -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()