Files
test/effects/ascii_art.py
gilesb 406cc7c0c7 Initial commit: video effects processing system
Add S-expression based video effects pipeline with modular effect
definitions, constructs, and recipe files.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 12:34:45 +00:00

206 lines
6.6 KiB
Python

# /// 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