# /// script # requires-python = ">=3.10" # dependencies = ["numpy", "opencv-python"] # /// """ @effect ascii_art @version 1.0.0 @author artdag @description ASCII art effect - converts video to ASCII character representation. Renders the video using ASCII characters based on brightness, creating a retro terminal aesthetic. @param char_size int @range 4 32 @default 8 Size of each character cell in pixels. Sync to bass for reactive sizing. @param alphabet string @enum standard blocks cyrillic greek japanese braille @default standard Character set to use for rendering. Each has different visual density. @param color_mode string @enum mono color invert @default color Color rendering mode: - mono: white on black - color: preserve source colors - invert: dark text on colored background @param contrast_boost float @range 1 3 @default 1.5 Enhance contrast for better character separation. @param background list @default [0, 0, 0] Background color RGB. @example (effect ascii_art :char_size 8 :color_mode "color") @example ;; Japanese characters, reactive sizing (effect ascii_art :alphabet "japanese" :char_size (bind bass :range [6 16])) @example ;; Braille pattern for high detail (effect ascii_art :alphabet "braille" :char_size 4) """ import numpy as np import cv2 # Character sets ordered by visual density (light to dark) ALPHABETS = { # Classic ASCII gradient "standard": " .`'^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$", # Unicode block elements - naturally ordered by fill "blocks": " ░▒▓█", # Cyrillic - ordered by visual complexity "cyrillic": " ·гтпрсьоеаилнкчуцбдвжзмъыюяфщшэ", # Greek - ordered by visual weight "greek": " ·ιτορεαηυικλνσςπμβγδζθξφψωΣΩΨΦ", # Japanese Katakana - ordered by stroke complexity "japanese": " ·ノ一ヘイコニハヒフホメヨワヲンリルレロカキクケサシスセソタチツテト", # Braille patterns - high detail "braille": " ⠁⠂⠃⠄⠅⠆⠇⠈⠉⠊⠋⠌⠍⠎⠏⠐⠑⠒⠓⠔⠕⠖⠗⠘⠙⠚⠛⠜⠝⠞⠟⠠⠡⠢⠣⠤⠥⠦⠧⠨⠩⠪⠫⠬⠭⠮⠯⠰⠱⠲⠳⠴⠵⠶⠷⠸⠹⠺⠻⠼⠽⠾⠿", } def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple: """ Apply ASCII art effect to a video frame. Args: frame: Input frame as numpy array (H, W, 3) RGB uint8 params: Effect parameters - char_size: character cell size (default 8) - alphabet: character set name (default "standard") - color_mode: mono/color/invert (default "color") - contrast_boost: enhance contrast (default 1.5) - background: RGB tuple (default [0,0,0]) state: Persistent state dict (used for caching) Returns: Tuple of (processed_frame, new_state) """ char_size = max(4, min(int(params.get("char_size", 8)), 32)) alphabet_name = params.get("alphabet", "standard") color_mode = params.get("color_mode", "color") contrast_boost = max(1.0, min(params.get("contrast_boost", 1.5), 3.0)) background = params.get("background", [0, 0, 0]) if state is None: state = {} # Get character set char_set = ALPHABETS.get(alphabet_name, ALPHABETS["standard"]) # Get or create character atlas cache cache_key = f"{char_size}_{alphabet_name}" if "atlas_cache" not in state or state.get("cache_key") != cache_key: state["atlas"] = _create_char_atlas(char_size, char_set) state["cache_key"] = cache_key atlas = state["atlas"] h, w = frame.shape[:2] cols = w // char_size rows = h // char_size if cols < 1 or rows < 1: return frame, state # Crop frame to fit grid grid_h, grid_w = rows * char_size, cols * char_size frame_cropped = frame[:grid_h, :grid_w] # Downsample to get average color per cell reshaped = frame_cropped.reshape(rows, char_size, cols, char_size, 3) cell_colors = reshaped.mean(axis=(1, 3)).astype(np.uint8) # Convert to grayscale for brightness mapping cell_gray = 0.299 * cell_colors[:,:,0] + 0.587 * cell_colors[:,:,1] + 0.114 * cell_colors[:,:,2] # Apply contrast boost if contrast_boost > 1: cell_gray = (cell_gray - 128) * contrast_boost + 128 cell_gray = np.clip(cell_gray, 0, 255) # Map brightness to character indices char_indices = ((cell_gray / 255) * (len(char_set) - 1)).astype(np.int32) char_indices = np.clip(char_indices, 0, len(char_set) - 1) # Create output frame if isinstance(background, (list, tuple)) and len(background) >= 3: bg = background[:3] else: bg = [0, 0, 0] result = np.full((grid_h, grid_w, 3), bg, dtype=np.uint8) # Render characters for row in range(rows): for col in range(cols): char_idx = char_indices[row, col] char = char_set[char_idx] char_mask = atlas.get(char) if char_mask is None: continue y1, x1 = row * char_size, col * char_size if color_mode == "mono": color = np.array([255, 255, 255], dtype=np.uint8) elif color_mode == "invert": # Colored background, dark text result[y1:y1+char_size, x1:x1+char_size] = cell_colors[row, col] color = np.array([0, 0, 0], dtype=np.uint8) else: # color color = cell_colors[row, col] # Apply character mask if char != ' ': mask = char_mask > 0 result[y1:y1+char_size, x1:x1+char_size][mask] = color # Pad to original size if needed if result.shape[0] < h or result.shape[1] < w: padded = np.full((h, w, 3), bg, dtype=np.uint8) padded[:grid_h, :grid_w] = result result = padded return result, state def _create_char_atlas(char_size: int, char_set: str) -> dict: """Pre-render all characters as masks.""" font = cv2.FONT_HERSHEY_SIMPLEX font_scale = char_size / 20.0 thickness = max(1, int(char_size / 10)) atlas = {} for char in char_set: char_img = np.zeros((char_size, char_size), dtype=np.uint8) if char != ' ': try: (text_w, text_h), baseline = cv2.getTextSize(char, font, font_scale, thickness) text_x = (char_size - text_w) // 2 text_y = (char_size + text_h) // 2 cv2.putText(char_img, char, (text_x, text_y), font, font_scale, 255, thickness, cv2.LINE_AA) except: pass atlas[char] = char_img return atlas