diff --git a/README.md b/README.md index 3c10fd3..a7863eb 100644 --- a/README.md +++ b/README.md @@ -1,8 +1,8 @@ # ComfyUI-Mircify -A ComfyUI custom node for converting images into IRC art by processing them in pixel blocks and generating both image and text outputs with optimal color selection from a 99-color IRC palette. +A ComfyUI custom node for converting images into IRC art by processing them in pixel blocks and generating image and text outputs with optimal color selection from a 99-color IRC palette. Now supports both IRC and ANSI terminal text outputs for maximum compatibility. -This node is used with [aibird](https://github.com/birdneststream/aibird) to convert text prompts to IRC art that is scrolled in the IRC client. +This node is used with [aibird](https://github.com/birdneststream/aibird) to convert text prompts to IRC art that is scrolled in the IRC client, and can also output ANSI-formatted text for terminal display. ## Limitations @@ -25,6 +25,25 @@ Pasting the results of the created `.txt` file into IRC, we should be able to se ![IRC output](workflows/screenshot_irc.png) +## ANSI Terminal Output + +The node now also generates ANSI-formatted text that can be displayed directly in terminals with 256-color support. The ANSI output uses: + +- **256-color palette**: Maps IRC colors to the closest ANSI 256-color equivalents using RGB distance calculations +- **Background colors with spaces**: Creates solid color blocks for consistent rendering +- **Half-block support**: Uses the ▀ character with foreground/background colors for different top/bottom colors +- **Automatic color reset**: Properly resets colors at the end of each line + +To view ANSI output in a terminal: +```bash +cat output/irc_art/your_ansi_file.txt +``` + +or display it with `less -R` to preserve colors: +```bash +less -R output/irc_art/your_ansi_file.txt +``` + ## Features - **Universal Image Support**: Works with any image dimensions, but remember the IRC 512 byte limit per line. @@ -35,8 +54,10 @@ Pasting the results of the created `.txt` file into IRC, we should be able to se - **Multiple Distance Methods**: Choose between weighted manhattan, manhattan, euclidean, and perceptual weighted distance calculations - **99-Color IRC Palette**: Maps colors to extended IRC color palette - **16-Color Compatibility Mode**: Option to use traditional 16-color IRC palette -- **Dual Output**: Returns both processed image and mIRC-formatted text -- **Optimized Text Output**: Compressed color codes and space-efficient formatting +- **Dual Text Output**: Returns both IRC-formatted and ANSI-formatted text +- **ANSI Terminal Support**: Generate ANSI escape sequences for terminal display with 256-color accuracy +- **Intelligent Output Generation**: Only generates text outputs that are connected, optimizing performance +- **Optimized Text Output**: Compressed color codes and space-efficient formatting for both IRC and ANSI - **Text File Export**: Save IRC codes directly to text files with timestamp support ## Installation @@ -73,6 +94,7 @@ Converts images into IRC art with both visual and text output. **Outputs:** - **processed_image**: Visual representation of the IRC art conversion - **irc_text**: mIRC-formatted text with color codes ready for IRC clients +- **ansi_text**: ANSI-formatted text with 256-color escape sequences for terminal display ### IRC Text Saver **Category**: `text/output` @@ -91,7 +113,8 @@ Saves IRC-formatted text to files for easy copying. 1. **Basic Workflow:** - Load an image - Connect it to "IRC Art Converter" - - Connect the `irc_text` output to "IRC Text Saver" + - Connect the `irc_text` output to "IRC Text Saver" (for IRC clients) + - Optionally connect the `ansi_text` output to display in terminals - Run the workflow 2. **Distance Methods:** @@ -109,9 +132,10 @@ Saves IRC-formatted text to files for easy copying. - **16-color mode**: Traditional IRC colors for better compatibility 5. **Text Output:** - - Uses optimized mIRC color text output - - Ready to paste directly into IRC clients - - Support for {TIMESTAMP} placeholder in filenames + - **IRC Output**: Uses optimized mIRC color text output ready to paste directly into IRC clients + - **ANSI Output**: Uses ANSI 256-color escape sequences for terminal display with accurate color mapping + - **Performance Optimization**: Only generates text for outputs that are actually connected to other nodes + - **Support**: {TIMESTAMP} placeholder in filenames for IRC Text Saver ## Color Processing diff --git a/pyproject.toml b/pyproject.toml index 2615c3a..8be1c90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ name = "comfyui-mircify" version = "1.0.0" description = "ComfyUI node for converting images to IRC art blocks" authors = [ - {name = "ComfyUI-Mircify", email = "noreply@example.com"} + {name = "ComfyUI-Mircify", email = "bird@birdnest.live"} ] license = {text = "MIT"} readme = "README.md" @@ -21,4 +21,4 @@ dependencies = [ keywords = ["comfyui", "irc", "art", "image-processing"] [project.urls] -Repository = "https://github.com/example/ComfyUI-Mircify" \ No newline at end of file +Repository = "https://github.com/birdneststream/ComfyUI-Mircify" \ No newline at end of file diff --git a/src/nodes.py b/src/nodes.py index 93ee1a0..8cc334e 100644 --- a/src/nodes.py +++ b/src/nodes.py @@ -1,167 +1,179 @@ +""" +IRC Art Converter Node for ComfyUI +Converts images to IRC art with text output support for both IRC and ANSI terminals. +""" + import torch import numpy as np from sklearn.cluster import KMeans from datetime import datetime +import warnings +import os +from typing import Tuple, List, Optional, Union, Dict, Any -class IRCArtConverter: - CATEGORY = "image/conversion" + +# ============================================================================ +# CONSTANTS +# ============================================================================ + +# 99-color IRC palette - matches Go IRCColorPalette exactly +IRC_COLORS = [ + (255, 255, 255), (0, 0, 0), (0, 0, 127), (0, 147, 0), (255, 0, 0), + (127, 0, 0), (156, 0, 156), (252, 127, 0), (255, 255, 0), (0, 252, 0), + (0, 147, 147), (0, 255, 255), (0, 0, 252), (255, 0, 255), (127, 127, 127), + (210, 210, 210), (71, 0, 0), (71, 33, 0), (71, 71, 0), (50, 71, 0), + (0, 71, 0), (0, 71, 44), (0, 71, 71), (0, 39, 71), (0, 0, 71), + (46, 0, 71), (71, 0, 71), (71, 0, 42), (116, 0, 0), (116, 58, 0), + (116, 116, 0), (81, 116, 0), (0, 116, 0), (0, 116, 73), (0, 116, 116), + (0, 64, 116), (0, 0, 116), (75, 0, 116), (116, 0, 116), (116, 0, 69), + (181, 0, 0), (181, 99, 0), (181, 181, 0), (125, 181, 0), (0, 181, 0), + (0, 181, 113), (0, 181, 181), (0, 99, 181), (0, 0, 181), (117, 0, 181), + (181, 0, 181), (181, 0, 107), (255, 0, 0), (255, 140, 0), (255, 255, 0), + (178, 255, 0), (0, 255, 0), (0, 255, 160), (0, 255, 255), (0, 140, 255), + (0, 0, 255), (165, 0, 255), (255, 0, 255), (255, 0, 152), (255, 89, 89), + (255, 180, 89), (255, 255, 113), (207, 255, 96), (111, 255, 111), (101, 255, 201), + (109, 255, 255), (89, 180, 255), (89, 89, 255), (196, 89, 255), (255, 102, 255), + (255, 89, 188), (255, 156, 156), (255, 211, 156), (255, 255, 156), (226, 255, 156), + (156, 255, 156), (156, 255, 219), (156, 255, 255), (156, 211, 255), (156, 156, 255), + (220, 156, 255), (255, 156, 255), (255, 148, 211), (0, 0, 0), (19, 19, 19), + (40, 40, 40), (54, 54, 54), (77, 77, 77), (101, 101, 101), (129, 129, 129), + (159, 159, 159), (188, 188, 188), (226, 226, 226), (255, 255, 255) +] + +# ANSI standard colors (0-15) +ANSI_STANDARD_COLORS = [ + (0, 0, 0), (128, 0, 0), (0, 128, 0), (128, 128, 0), + (0, 0, 128), (128, 0, 128), (0, 128, 128), (192, 192, 192), + (128, 128, 128), (255, 0, 0), (0, 255, 0), (255, 255, 0), + (0, 0, 255), (255, 0, 255), (0, 255, 255), (255, 255, 255) +] + +# RGB values for 6x6x6 cube mapping +RGB_CUBE_VALUES = [0, 95, 135, 175, 215, 255] + +# Distance method weights +DISTANCE_WEIGHTS = { + 'weighted_manhattan': np.array([0.7, 1.0, 0.5]), # Human visual sensitivity + 'perceptual_weighted': np.array([0.299, 0.587, 0.114]) # Luminance weights +} + +# Block processing defaults +DEFAULT_BLOCK_WIDTH = 8 +DEFAULT_BLOCK_HEIGHT = 15 +HALF_BLOCK_DIVISOR = 2 +MIN_COLOR_VARIANCE = 0.01 +MAX_KMEANS_CLUSTERS = 3 +KMEANS_N_INIT = 10 + +# Character constants +HALF_BLOCK_CHAR = "▀" +SPACE_CHAR = " " +ANSI_RESET = "\033[0m" + + +# ============================================================================ +# COLOR UTILITIES +# ============================================================================ + +class ColorUtils: + """Utility class for color-related operations.""" - # 99-color IRC palette (class constant) - matches Go IRCColorPalette exactly - IRC_COLORS = [ - (255, 255, 255), (0, 0, 0), (0, 0, 127), (0, 147, 0), (255, 0, 0), - (127, 0, 0), (156, 0, 156), (252, 127, 0), (255, 255, 0), (0, 252, 0), - (0, 147, 147), (0, 255, 255), (0, 0, 252), (255, 0, 255), (127, 127, 127), - (210, 210, 210), (71, 0, 0), (71, 33, 0), (71, 71, 0), (50, 71, 0), - (0, 71, 0), (0, 71, 44), (0, 71, 71), (0, 39, 71), (0, 0, 71), - (46, 0, 71), (71, 0, 71), (71, 0, 42), (116, 0, 0), (116, 58, 0), - (116, 116, 0), (81, 116, 0), (0, 116, 0), (0, 116, 73), (0, 116, 116), - (0, 64, 116), (0, 0, 116), (75, 0, 116), (116, 0, 116), (116, 0, 69), - (181, 0, 0), (181, 99, 0), (181, 181, 0), (125, 181, 0), (0, 181, 0), - (0, 181, 113), (0, 181, 181), (0, 99, 181), (0, 0, 181), (117, 0, 181), - (181, 0, 181), (181, 0, 107), (255, 0, 0), (255, 140, 0), (255, 255, 0), - (178, 255, 0), (0, 255, 0), (0, 255, 160), (0, 255, 255), (0, 140, 255), - (0, 0, 255), (165, 0, 255), (255, 0, 255), (255, 0, 152), (255, 89, 89), - (255, 180, 89), (255, 255, 113), (207, 255, 96), (111, 255, 111), (101, 255, 201), - (109, 255, 255), (89, 180, 255), (89, 89, 255), (196, 89, 255), (255, 102, 255), - (255, 89, 188), (255, 156, 156), (255, 211, 156), (255, 255, 156), (226, 255, 156), - (156, 255, 156), (156, 255, 219), (156, 255, 255), (156, 211, 255), (156, 156, 255), - (220, 156, 255), (255, 156, 255), (255, 148, 211), (0, 0, 0), (19, 19, 19), - (40, 40, 40), (54, 54, 54), (77, 77, 77), (101, 101, 101), (129, 129, 129), - (159, 159, 159), (188, 188, 188), (226, 226, 226), (255, 255, 255) - ] + @staticmethod + def get_ansi_rgb(color_num: int) -> Tuple[int, int, int]: + """Get RGB values for ANSI 256 color number.""" + if color_num <= 7: + return ANSI_STANDARD_COLORS[color_num] + elif color_num <= 15: + return ANSI_STANDARD_COLORS[color_num] + elif color_num <= 231: + # 6x6x6 RGB cube + idx = color_num - 16 + r = idx // 36 + g = (idx % 36) // 6 + b = idx % 6 + return (RGB_CUBE_VALUES[r], RGB_CUBE_VALUES[g], RGB_CUBE_VALUES[b]) + else: + # Grayscale 232-255 + gray = 8 + (color_num - 232) * 10 + return (gray, gray, gray) - @classmethod - def INPUT_TYPES(s): - return { - "required": { - "image": ("IMAGE",), - "block_width": ("INT", {"default": 8, "min": 1, "max": 32}), - "block_height": ("INT", {"default": 15, "min": 1, "max": 32}), - "half_block_mode": ("BOOLEAN", {"default": False}), - "use_16_colors": ("BOOLEAN", {"default": False}), - "distance_method": (["weighted_manhattan", "manhattan", "euclidean", "perceptual_weighted"], {"default": "weighted_manhattan"}), - } - } + @staticmethod + def find_closest_ansi_color(target_rgb: Tuple[int, int, int]) -> int: + """Find the closest ANSI 256 color to the target RGB.""" + min_distance = float('inf') + closest_color = 0 + + for i in range(256): + ansi_rgb = ColorUtils.get_ansi_rgb(i) + # Euclidean distance + distance = sum((a - b) ** 2 for a, b in zip(target_rgb, ansi_rgb)) ** 0.5 + if distance < min_distance: + min_distance = distance + closest_color = i + + return closest_color - RETURN_TYPES = ("IMAGE", "STRING") - RETURN_NAMES = ("processed_image", "irc_text") - FUNCTION = "convert_to_irc_art" + @staticmethod + def generate_irc_to_ansi_mapping() -> List[int]: + """Generate IRC to ANSI color mapping based on RGB distance.""" + return [ColorUtils.find_closest_ansi_color(irc_rgb) for irc_rgb in IRC_COLORS] + + +class DistanceCalculator: + """Handles various color distance calculation methods.""" - def calculate_color_distance(self, detected_color, target_colors, method): - """Calculate color distance using various methods.""" + @staticmethod + def calculate(detected_color: np.ndarray, target_colors: np.ndarray, + method: str) -> np.ndarray: + """Calculate color distance using the specified method.""" + if method == "manhattan": - # Standard Manhattan distance (L1 norm) return np.sum(np.abs(detected_color - target_colors), axis=1) elif method == "weighted_manhattan": - # Weighted Manhattan distance for better perceptual matching - # Weights based on human visual sensitivity: [R, G, B] - weights = np.array([0.7, 1.0, 0.5]) + weights = DISTANCE_WEIGHTS['weighted_manhattan'] diff = np.abs(detected_color - target_colors) return np.sum(diff * weights, axis=1) elif method == "euclidean": - # Standard Euclidean distance (L2 norm) return np.sqrt(np.sum((detected_color - target_colors) ** 2, axis=1)) elif method == "perceptual_weighted": - # Perceptual weighted Euclidean distance - # Based on CIE recommendations for RGB weights - weights = np.array([0.299, 0.587, 0.114]) # Luminance weights + weights = DISTANCE_WEIGHTS['perceptual_weighted'] diff = (detected_color - target_colors) ** 2 return np.sqrt(np.sum(diff * weights, axis=1)) - else: - # Fallback to weighted manhattan - return self.calculate_color_distance(detected_color, target_colors, "weighted_manhattan") + # Default fallback to weighted_manhattan + return DistanceCalculator.calculate(detected_color, target_colors, "weighted_manhattan") + + +# ============================================================================ +# TEXT FORMATTERS +# ============================================================================ + +class TextFormatter: + """Base class for text formatting operations.""" - def apply_color_transfer(self, image, use_16_colors=False, distance_method="weighted_manhattan"): - """Apply color transfer using KMeans clustering and configurable distance matching.""" - # Convert image to numpy array - img_array = (image * 255.0).cpu().numpy().astype(np.uint8) - img_flat = img_array.reshape((-1, 3)) - - # Select color palette based on mode - target_colors = self.IRC_COLORS[:16] if use_16_colors else self.IRC_COLORS - - # KMeans clustering - clustering_model = KMeans(n_clusters=len(target_colors), n_init="auto", random_state=42) - clustering_model.fit(img_flat) - - detected_colors = clustering_model.cluster_centers_.astype(int) - - # Match detected colors to IRC palette using configured distance method - target_colors_array = np.array(target_colors) - closest_colors = [] - - for color in detected_colors: - distances = self.calculate_color_distance(color, target_colors_array, distance_method) - closest_colors.append(target_colors_array[np.argmin(distances)]) - - closest_colors = np.array(closest_colors) - - # Map each pixel to its closest IRC color - processed_image = closest_colors[clustering_model.labels_].reshape(img_array.shape) - - # Convert back to torch tensor - return torch.from_numpy(processed_image.astype(np.float32) / 255.0) - - def get_block_color(self, block): - """Extract dominant color from a block using k-means clustering.""" - # Use k-means to find dominant color - block_flat = block.reshape(-1, 3).cpu().numpy() - if len(block_flat) < 2: - return tuple(block_flat[0] * 255) - - # Check for color variance - color_std = np.std(block_flat, axis=0) - if np.max(color_std) < 0.01: # Very uniform color - return tuple(np.mean(block_flat, axis=0) * 255) - - # Find unique colors to determine appropriate cluster count - unique_colors = np.unique(block_flat, axis=0) - n_clusters = min(3, len(unique_colors)) - - if n_clusters == 1: - return tuple(unique_colors[0] * 255) - - # Use k-means with appropriate cluster count - try: - import warnings - with warnings.catch_warnings(): - warnings.simplefilter("ignore") - kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=10) - kmeans.fit(block_flat) + @staticmethod + def count_consecutive(items: List, index: int) -> int: + """Count consecutive identical entries starting from index.""" + if index >= len(items): + return 0 - # Find the cluster with most points - labels = kmeans.labels_ - unique, counts = np.unique(labels, return_counts=True) - dominant_cluster = unique[np.argmax(counts)] - dominant_color = kmeans.cluster_centers_[dominant_cluster] - - return tuple(dominant_color * 255) - except: - # Fallback to average if k-means fails - return tuple(block.mean(dim=(0, 1)).cpu().numpy() * 255) - - def find_irc_color_index(self, block_color, use_16_colors, distance_method="weighted_manhattan"): - """Find the IRC color index for a given block color.""" - # Get representative color for this block - block_color_rounded = tuple(np.round(block_color).astype(int)) + count = 1 + current = items[index] - # Select palette based on mode - palette = self.IRC_COLORS[:16] if use_16_colors else self.IRC_COLORS + while index + count < len(items) and items[index + count] == current: + count += 1 - # Try exact match first (since colors should already be in IRC palette) - try: - return palette.index(block_color_rounded) - except ValueError: - # Fallback to distance matching if exact match fails - target_colors_array = np.array(palette) - distances = self.calculate_color_distance(np.array(block_color), target_colors_array, distance_method) - return np.argmin(distances) + return count + + +class IRCFormatter(TextFormatter): + """Handles IRC-specific text formatting.""" - def _format_color_code(self, fg_color, bg_color=None): + @staticmethod + def format_color_code(fg_color: int, bg_color: Optional[int] = None) -> str: """Format IRC color code with proper padding.""" if bg_color is None: bg_color = fg_color @@ -170,202 +182,424 @@ class IRCArtConverter: bg_fmt = f"{bg_color:02d}" if bg_color >= 10 else str(bg_color) return f"\x03{fg_fmt},{bg_fmt}" - def _count_consecutive(self, colors, index, is_tuple_check=False): - """Count consecutive identical entries starting from index.""" - count = 1 - current = colors[index] - - while index + count < len(colors): - next_entry = colors[index + count] - # Check type consistency and value equality - if (is_tuple_check and not isinstance(next_entry, tuple)) or next_entry != current: - break - count += 1 - - return count - - def format_irc_line(self, line_colors): + @staticmethod + def format_line(line_colors: List[Union[int, Tuple[int, int]]]) -> str: """Format a line of IRC color codes with compression.""" if not line_colors: return "" - line_text = "" + result = [] i = 0 while i < len(line_colors): color_entry = line_colors[i] if isinstance(color_entry, tuple): - # Half-block mode: (top_color, bottom_color) + # Half-block mode top_color, bottom_color = color_entry if top_color == bottom_color: - # Same color - compress consecutive identical blocks - count = self._count_consecutive(line_colors, i, is_tuple_check=True) - line_text += self._format_color_code(top_color) + (" " * count) + # Compress consecutive identical blocks + count = 1 + while (i + count < len(line_colors) and + isinstance(line_colors[i + count], tuple) and + line_colors[i + count] == color_entry): + count += 1 + + result.append(IRCFormatter.format_color_code(top_color) + (SPACE_CHAR * count)) i += count else: # Different colors - use half block character - line_text += self._format_color_code(top_color, bottom_color) + "▀" + result.append(IRCFormatter.format_color_code(top_color, bottom_color) + HALF_BLOCK_CHAR) i += 1 else: - # Full-block mode: single color - count = self._count_consecutive(line_colors, i) - line_text += self._format_color_code(color_entry) + (" " * count) + # Full-block mode + count = IRCFormatter.count_consecutive(line_colors, i) + result.append(IRCFormatter.format_color_code(color_entry) + (SPACE_CHAR * count)) i += count - return line_text + return ''.join(result) + + +class ANSIFormatter(TextFormatter): + """Handles ANSI terminal text formatting.""" - def process_block(self, color_transferred_img, x, y, block_width, block_height, - actual_block_height, width, height, use_16_colors, distance_method): - """Process a single block and return its color index.""" - # Calculate block boundaries + def __init__(self, irc_to_ansi_mapping: List[int]): + self.mapping = irc_to_ansi_mapping + + def get_ansi_color(self, irc_idx: int) -> int: + """Get ANSI color for IRC color index with bounds checking.""" + if 0 <= irc_idx < len(self.mapping): + return self.mapping[irc_idx] + return self.mapping[0] # Default to first color + + def format_background(self, ansi_color: int, count: int = 1) -> str: + """Format ANSI background color with spaces.""" + return f"\033[48;5;{ansi_color}m" + (SPACE_CHAR * count) + + def format_half_block(self, top_ansi: int, bottom_ansi: int) -> str: + """Format ANSI half-block with foreground and background colors.""" + return f"\033[38;5;{top_ansi};48;5;{bottom_ansi}m{HALF_BLOCK_CHAR}" + + def format_line(self, line_colors: List[Union[int, Tuple[int, int]]]) -> str: + """Format a line of ANSI color codes with compression.""" + if not line_colors: + return "" + + result = [] + i = 0 + + while i < len(line_colors): + color_entry = line_colors[i] + + if isinstance(color_entry, tuple): + # Half-block mode + top_color, bottom_color = color_entry + + if top_color == bottom_color: + # Same color - compress + count = 1 + while (i + count < len(line_colors) and + isinstance(line_colors[i + count], tuple) and + line_colors[i + count] == color_entry): + count += 1 + + ansi_color = self.get_ansi_color(top_color) + result.append(self.format_background(ansi_color, count)) + i += count + else: + # Different colors - half block + top_ansi = self.get_ansi_color(top_color) + bottom_ansi = self.get_ansi_color(bottom_color) + result.append(self.format_half_block(top_ansi, bottom_ansi)) + i += 1 + else: + # Full-block mode + count = ANSIFormatter.count_consecutive(line_colors, i) + ansi_color = self.get_ansi_color(color_entry) + result.append(self.format_background(ansi_color, count)) + i += count + + result.append(ANSI_RESET) + return ''.join(result) + + +# ============================================================================ +# IMAGE PROCESSING +# ============================================================================ + +class BlockProcessor: + """Handles block-based image processing operations.""" + + @staticmethod + def get_dominant_color(block: torch.Tensor) -> Tuple[int, int, int]: + """Extract dominant color from a block using k-means clustering.""" + block_flat = block.reshape(-1, 3).cpu().numpy() + + # Handle edge cases + if len(block_flat) < 2: + return tuple((block_flat[0] * 255).astype(int)) + + # Check for uniform color + color_std = np.std(block_flat, axis=0) + if np.max(color_std) < MIN_COLOR_VARIANCE: + return tuple((np.mean(block_flat, axis=0) * 255).astype(int)) + + # Determine cluster count + unique_colors = np.unique(block_flat, axis=0) + n_clusters = min(MAX_KMEANS_CLUSTERS, len(unique_colors)) + + if n_clusters == 1: + return tuple((unique_colors[0] * 255).astype(int)) + + # Apply k-means clustering + try: + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + kmeans = KMeans(n_clusters=n_clusters, random_state=42, n_init=KMEANS_N_INIT) + kmeans.fit(block_flat) + + # Find dominant cluster + unique, counts = np.unique(kmeans.labels_, return_counts=True) + dominant_cluster = unique[np.argmax(counts)] + dominant_color = kmeans.cluster_centers_[dominant_cluster] + + return tuple((dominant_color * 255).astype(int)) + except Exception: + # Fallback to average + return tuple((block.mean(dim=(0, 1)).cpu().numpy() * 255).astype(int)) + + @staticmethod + def extract_block(image: torch.Tensor, x: int, y: int, + block_width: int, actual_block_height: float, + img_width: int, img_height: int) -> torch.Tensor: + """Extract a block from the image with proper boundaries.""" start_x = x * block_width - end_x = min(start_x + block_width, width) + end_x = min(start_x + block_width, img_width) start_y = round(y * actual_block_height) - end_y = round(min((y + 1) * actual_block_height, height)) + end_y = round(min((y + 1) * actual_block_height, img_height)) - # Extract block - block = color_transferred_img[start_y:end_y, start_x:end_x, :] + return image[start_y:end_y, start_x:end_x, :] + + +class ColorTransfer: + """Handles color transfer operations to map images to IRC palette.""" + + def __init__(self, distance_calculator: DistanceCalculator): + self.distance_calc = distance_calculator + + def apply(self, image: torch.Tensor, use_16_colors: bool = False, + distance_method: str = "weighted_manhattan") -> torch.Tensor: + """Apply color transfer using KMeans clustering and distance matching.""" + # Convert to numpy + img_array = (image * 255.0).cpu().numpy().astype(np.uint8) + img_flat = img_array.reshape((-1, 3)) + + # Select palette + target_colors = IRC_COLORS[:16] if use_16_colors else IRC_COLORS + + # Apply KMeans + clustering_model = KMeans(n_clusters=len(target_colors), n_init="auto", random_state=42) + clustering_model.fit(img_flat) + + detected_colors = clustering_model.cluster_centers_.astype(int) + + # Match to IRC palette + target_colors_array = np.array(target_colors) + closest_colors = [] + + for color in detected_colors: + distances = self.distance_calc.calculate(color, target_colors_array, distance_method) + closest_colors.append(target_colors_array[np.argmin(distances)]) + + closest_colors = np.array(closest_colors) + + # Map pixels + processed_image = closest_colors[clustering_model.labels_].reshape(img_array.shape) + + return torch.from_numpy(processed_image.astype(np.float32) / 255.0) + + +# ============================================================================ +# MAIN NODE CLASSES +# ============================================================================ + +class IRCArtConverter: + """Main node for converting images to IRC art.""" + + CATEGORY = "image/conversion" + + def __init__(self): + self.distance_calc = DistanceCalculator() + self.color_transfer = ColorTransfer(self.distance_calc) + self.block_processor = BlockProcessor() + self._irc_to_ansi_mapping = None + self._ansi_formatter = None + self._irc_formatter = IRCFormatter() + + @property + def ansi_formatter(self) -> ANSIFormatter: + """Lazy initialization of ANSI formatter.""" + if self._ansi_formatter is None: + if self._irc_to_ansi_mapping is None: + self._irc_to_ansi_mapping = ColorUtils.generate_irc_to_ansi_mapping() + self._ansi_formatter = ANSIFormatter(self._irc_to_ansi_mapping) + return self._ansi_formatter + + def _is_output_connected(self, prompt: Optional[Dict], node_id: Optional[str], output_index: int) -> bool: + """Check if a specific output is connected to another node.""" + if not prompt or not node_id: + return True # Default to generating output if we can't determine connection status + + try: + # Check all nodes in the prompt to see if any use this node's output + for node_data in prompt.values(): + if isinstance(node_data, dict) and "inputs" in node_data: + inputs = node_data["inputs"] + for input_value in inputs.values(): + # ComfyUI represents connections as [node_id, output_index] + if (isinstance(input_value, list) and + len(input_value) == 2 and + input_value[0] == node_id and + input_value[1] == output_index): + return True + return False + except Exception: + # If there's any error in parsing, default to generating the output + return True + + @classmethod + def INPUT_TYPES(cls) -> Dict[str, Any]: + return { + "required": { + "image": ("IMAGE",), + "block_width": ("INT", {"default": DEFAULT_BLOCK_WIDTH, "min": 1, "max": 32}), + "block_height": ("INT", {"default": DEFAULT_BLOCK_HEIGHT, "min": 1, "max": 32}), + "half_block_mode": ("BOOLEAN", {"default": False}), + "use_16_colors": ("BOOLEAN", {"default": False}), + "distance_method": (["weighted_manhattan", "manhattan", "euclidean", "perceptual_weighted"], + {"default": "perceptual_weighted"}), + } + } + + RETURN_TYPES = ("IMAGE", "STRING", "STRING") + RETURN_NAMES = ("processed_image", "irc_text", "ansi_text") + FUNCTION = "convert_to_irc_art" + + def find_irc_color_index(self, color: Tuple[int, int, int], use_16_colors: bool, + distance_method: str) -> int: + """Find the IRC color index for a given color.""" + # Select palette + palette = IRC_COLORS[:16] if use_16_colors else IRC_COLORS + + # Try exact match first + color_rounded = tuple(np.round(color).astype(int)) + try: + return palette.index(color_rounded) + except ValueError: + # Fallback to distance matching + target_colors_array = np.array(palette) + distances = self.distance_calc.calculate(np.array(color), target_colors_array, distance_method) + return int(np.argmin(distances)) + + def process_block(self, image: torch.Tensor, x: int, y: int, params: Dict[str, Any]) -> Tuple[int, int, int]: + """Process a single block and return its color index and boundaries.""" + block = self.block_processor.extract_block( + image, x, y, params['block_width'], params['actual_block_height'], + params['width'], params['height'] + ) + + # Calculate boundaries for output + start_y = round(y * params['actual_block_height']) + end_y = round(min((y + 1) * params['actual_block_height'], params['height'])) - # Get IRC color index if block.numel() == 0: return 0, start_y, end_y - else: - block_color = self.get_block_color(block) - return self.find_irc_color_index(block_color, use_16_colors, distance_method), start_y, end_y + + block_color = self.block_processor.get_dominant_color(block) + color_idx = self.find_irc_color_index( + block_color, params['use_16_colors'], params['distance_method'] + ) + + return color_idx, start_y, end_y - def _apply_color_to_output(self, output, color_idx, start_y, end_y, start_x, end_x): + def apply_color_to_output(self, output: torch.Tensor, color_idx: int, + start_y: int, end_y: int, start_x: int, end_x: int) -> None: """Apply IRC color to output image region.""" - irc_color = self.IRC_COLORS[color_idx] - irc_color_tensor = torch.tensor(irc_color, dtype=torch.float32) / 255.0 - output[start_y:end_y, start_x:end_x, :] = irc_color_tensor + irc_color = torch.tensor(IRC_COLORS[color_idx], dtype=torch.float32) / 255.0 + output[start_y:end_y, start_x:end_x, :] = irc_color - def _process_half_block_row(self, color_transferred_img, y, blocks_x, blocks_y, output, params): - """Process a row of half-blocks.""" + def process_row(self, image: torch.Tensor, y: int, output: torch.Tensor, + params: Dict[str, Any], half_block: bool = False) -> List[Union[int, Tuple[int, int]]]: + """Process a row of blocks.""" line_colors = [] - block_width, block_height, actual_block_height = params['block_width'], params['block_height'], params['actual_block_height'] - width, height = params['width'], params['height'] - use_16_colors, distance_method = params['use_16_colors'], params['distance_method'] + blocks_x = params['blocks_x'] for x in range(blocks_x): - # Process first half-block - color_idx, start_y, end_y = self.process_block( - color_transferred_img, x, y, block_width, block_height, - actual_block_height, width, height, use_16_colors, distance_method - ) + # Process first block + color_idx1, start_y1, end_y1 = self.process_block(image, x, y, params) - # Apply color to image - x_start = x * block_width - x_end = min((x + 1) * block_width, width) - self._apply_color_to_output(output, color_idx, start_y, end_y, x_start, x_end) + # Apply to output + x_start = x * params['block_width'] + x_end = min((x + 1) * params['block_width'], params['width']) + self.apply_color_to_output(output, color_idx1, start_y1, end_y1, x_start, x_end) - # Process second half-block if it exists - if y + 1 < blocks_y: - color_idx2, start_y2, end_y2 = self.process_block( - color_transferred_img, x, y + 1, block_width, block_height, - actual_block_height, width, height, use_16_colors, distance_method - ) - - # Apply color to second block - self._apply_color_to_output(output, color_idx2, start_y2, end_y2, x_start, x_end) - - # Store both colors as tuple (top, bottom) - line_colors.append((color_idx, color_idx2)) + if half_block: + # Process second half-block if exists + if y + 1 < params['blocks_y']: + color_idx2, start_y2, end_y2 = self.process_block(image, x, y + 1, params) + self.apply_color_to_output(output, color_idx2, start_y2, end_y2, x_start, x_end) + line_colors.append((color_idx1, color_idx2)) + else: + line_colors.append((color_idx1, color_idx1)) else: - # Last row, only one half-block - use same color for both - line_colors.append((color_idx, color_idx)) + line_colors.append(color_idx1) return line_colors - def _process_full_block_row(self, color_transferred_img, y, blocks_x, output, params): - """Process a row of full blocks.""" - line_colors = [] - block_width, block_height, actual_block_height = params['block_width'], params['block_height'], params['actual_block_height'] - width, height = params['width'], params['height'] - use_16_colors, distance_method = params['use_16_colors'], params['distance_method'] + def convert_to_irc_art(self, image: torch.Tensor, block_width: int, block_height: int, + half_block_mode: bool, use_16_colors: bool, distance_method: str, + prompt=None, extra_pnginfo=None, my_unique_id=None) -> Tuple[torch.Tensor, str, str]: + """Main conversion function.""" + # Check which outputs are connected + irc_connected = self._is_output_connected(prompt, my_unique_id, 1) # irc_text is output index 1 + ansi_connected = self._is_output_connected(prompt, my_unique_id, 2) # ansi_text is output index 2 - for x in range(blocks_x): - color_idx, start_y, end_y = self.process_block( - color_transferred_img, x, y, block_width, block_height, - actual_block_height, width, height, use_16_colors, distance_method - ) - - # Apply color to image and store for text - x_start = x * block_width - x_end = min((x + 1) * block_width, width) - self._apply_color_to_output(output, color_idx, start_y, end_y, x_start, x_end) - line_colors.append(color_idx) - - return line_colors - - def convert_to_irc_art(self, image, block_width, block_height, half_block_mode, use_16_colors, distance_method): - # Input validation + # Validate input batch_size, height, width, channels = image.shape - if batch_size != 1: raise ValueError(f"Expected batch size 1, got {batch_size}") - # Get the first (and only) image from batch - img = image[0] # Shape: (height, width, 3) + img = image[0] - # Apply color transfer to map image to IRC palette - color_transferred_img = self.apply_color_transfer(img, use_16_colors, distance_method) + # Apply color transfer + color_transferred_img = self.color_transfer.apply(img, use_16_colors, distance_method) - # Calculate number of blocks + # Calculate block dimensions blocks_x = width // block_width if half_block_mode: blocks_y = (height * 2) // block_height - actual_block_height = block_height / 2 + actual_block_height = block_height / HALF_BLOCK_DIVISOR else: blocks_y = height // block_height - actual_block_height = block_height + actual_block_height = float(block_height) - # Create output image and text lines - output = torch.zeros_like(img) - text_lines = [] - - # Bundle parameters for cleaner function calls + # Prepare parameters params = { 'block_width': block_width, 'block_height': block_height, 'actual_block_height': actual_block_height, 'width': width, 'height': height, + 'blocks_x': blocks_x, + 'blocks_y': blocks_y, 'use_16_colors': use_16_colors, 'distance_method': distance_method } - # Process blocks + # Process image + output = torch.zeros_like(img) + irc_lines = [] + ansi_lines = [] + if half_block_mode: - # Half-block mode: process in pairs + # Process in pairs for half-block mode for y in range(0, blocks_y, 2): - line_colors = self._process_half_block_row(color_transferred_img, y, blocks_x, blocks_y, output, params) - text_lines.append(self.format_irc_line(line_colors)) + line_colors = self.process_row(color_transferred_img, y, output, params, half_block=True) + if irc_connected: + irc_lines.append(self._irc_formatter.format_line(line_colors)) + if ansi_connected: + ansi_lines.append(self.ansi_formatter.format_line(line_colors)) else: - # Full block mode + # Process full blocks for y in range(blocks_y): - line_colors = self._process_full_block_row(color_transferred_img, y, blocks_x, output, params) - text_lines.append(self.format_irc_line(line_colors)) + line_colors = self.process_row(color_transferred_img, y, output, params, half_block=False) + if irc_connected: + irc_lines.append(self._irc_formatter.format_line(line_colors)) + if ansi_connected: + ansi_lines.append(self.ansi_formatter.format_line(line_colors)) - # Join all lines with newlines - irc_text = "\n".join(text_lines) + # Format output + irc_text = "\n".join(irc_lines) if irc_connected else "" + ansi_text = "\n".join(ansi_lines) if ansi_connected else "" - # Return as batch of 1 - return (output.unsqueeze(0), irc_text) + return (output.unsqueeze(0), irc_text, ansi_text) class IRCTextSaver: - """Node to save IRC formatted text to a file""" + """Node to save IRC formatted text to a file.""" + CATEGORY = "text/output" + OUTPUT_DIR = "output/irc_art" + DEFAULT_FILENAME = "irc_art_{TIMESTAMP}.txt" @classmethod - def INPUT_TYPES(s): + def INPUT_TYPES(cls) -> Dict[str, Any]: return { "required": { "irc_text": ("STRING", {"forceInput": True}), - "filename": ("STRING", {"default": "irc_art_{TIMESTAMP}.txt"}), + "filename": ("STRING", {"default": cls.DEFAULT_FILENAME}), } } @@ -374,44 +608,44 @@ class IRCTextSaver: FUNCTION = "save_irc_text" OUTPUT_NODE = True - def save_irc_text(self, irc_text, filename): - """Save IRC text to a file""" - import os - - # Generate current timestamp + def save_irc_text(self, irc_text: str, filename: str) -> Dict[str, Any]: + """Save IRC text to a file.""" + # Generate timestamp timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") - # Replace {TIMESTAMP} placeholder in filename if present + # Process filename if "{TIMESTAMP}" in filename: filename = filename.replace("{TIMESTAMP}", timestamp) - # Ensure filename has .txt extension if not filename.endswith('.txt'): filename += '.txt' - # Create output directory if it doesn't exist - output_dir = "output/irc_art" - os.makedirs(output_dir, exist_ok=True) + # Create output directory + os.makedirs(self.OUTPUT_DIR, exist_ok=True) - # Create full file path - file_path = os.path.join(output_dir, filename) + # Save file + file_path = os.path.join(self.OUTPUT_DIR, filename) - # Write the IRC text to file try: with open(file_path, 'w', encoding='utf-8') as f: f.write(irc_text) abs_path = os.path.abspath(file_path) - success_msg = f"✅ IRC text saved to: {abs_path}" + success_msg = f"IRC text saved to: {abs_path}" print(success_msg) return {"ui": {"text": [success_msg]}, "result": (abs_path,)} except Exception as e: - error_msg = f"❌ Error saving file: {str(e)}" + error_msg = f"Error saving file: {str(e)}" print(error_msg) return {"ui": {"text": [error_msg]}, "result": (error_msg,)} + +# ============================================================================ +# NODE REGISTRATION +# ============================================================================ + NODE_CLASS_MAPPINGS = { "IRC Art Converter": IRCArtConverter, "IRC Text Saver": IRCTextSaver,