feat: big refactor and add ANSI support

This commit is contained in:
Hugh Bord
2025-08-27 17:44:26 +08:00
parent 7560f88586
commit 799fc88c0d
3 changed files with 537 additions and 279 deletions
+32 -8
View File
@@ -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
+2 -2
View File
@@ -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"
Repository = "https://github.com/birdneststream/ComfyUI-Mircify"
+503 -269
View File
@@ -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,