Files
celery/sexp_effects/primitives.py
giles bb458aa924 Replace batch DAG system with streaming architecture
- Remove legacy_tasks.py, hybrid_state.py, render.py
- Remove old task modules (analyze, execute, execute_sexp, orchestrate)
- Add streaming interpreter from test repo
- Add sexp_effects with primitives and video effects
- Add streaming Celery task with CID-based asset resolution
- Support both CID and friendly name references for assets
- Add .dockerignore to prevent local clones from conflicting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-02 19:10:11 +00:00

3044 lines
100 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Safe Primitives for S-Expression Effects
These are the building blocks that user-defined effects can use.
All primitives operate only on image data - no filesystem, network, etc.
"""
import numpy as np
import cv2
from typing import Any, Callable, Dict, List, Tuple, Optional
from dataclasses import dataclass
import math
@dataclass
class ZoneContext:
"""Context for a single cell/zone in ASCII art grid."""
row: int
col: int
row_norm: float # Normalized row position 0-1
col_norm: float # Normalized col position 0-1
luminance: float # Cell luminance 0-1
saturation: float # Cell saturation 0-1
hue: float # Cell hue 0-360
r: float # Red component 0-1
g: float # Green component 0-1
b: float # Blue component 0-1
class DeterministicRNG:
"""Seeded RNG for reproducible effects."""
def __init__(self, seed: int = 42):
self._rng = np.random.RandomState(seed)
def random(self, low: float = 0, high: float = 1) -> float:
return self._rng.uniform(low, high)
def randint(self, low: int, high: int) -> int:
return self._rng.randint(low, high + 1)
def gaussian(self, mean: float = 0, std: float = 1) -> float:
return self._rng.normal(mean, std)
# Global RNG instance (reset per frame with seed param)
_rng = DeterministicRNG()
def reset_rng(seed: int):
"""Reset the global RNG with a new seed."""
global _rng
_rng = DeterministicRNG(seed)
# =============================================================================
# Color Names (FFmpeg/X11 compatible)
# =============================================================================
NAMED_COLORS = {
# Basic colors
"black": (0, 0, 0),
"white": (255, 255, 255),
"red": (255, 0, 0),
"green": (0, 128, 0),
"blue": (0, 0, 255),
"yellow": (255, 255, 0),
"cyan": (0, 255, 255),
"magenta": (255, 0, 255),
# Grays
"gray": (128, 128, 128),
"grey": (128, 128, 128),
"darkgray": (169, 169, 169),
"darkgrey": (169, 169, 169),
"lightgray": (211, 211, 211),
"lightgrey": (211, 211, 211),
"dimgray": (105, 105, 105),
"dimgrey": (105, 105, 105),
"silver": (192, 192, 192),
# Reds
"darkred": (139, 0, 0),
"firebrick": (178, 34, 34),
"crimson": (220, 20, 60),
"indianred": (205, 92, 92),
"lightcoral": (240, 128, 128),
"salmon": (250, 128, 114),
"darksalmon": (233, 150, 122),
"lightsalmon": (255, 160, 122),
"tomato": (255, 99, 71),
"orangered": (255, 69, 0),
"coral": (255, 127, 80),
# Oranges
"orange": (255, 165, 0),
"darkorange": (255, 140, 0),
# Yellows
"gold": (255, 215, 0),
"lightyellow": (255, 255, 224),
"lemonchiffon": (255, 250, 205),
"papayawhip": (255, 239, 213),
"moccasin": (255, 228, 181),
"peachpuff": (255, 218, 185),
"palegoldenrod": (238, 232, 170),
"khaki": (240, 230, 140),
"darkkhaki": (189, 183, 107),
# Greens
"lime": (0, 255, 0),
"limegreen": (50, 205, 50),
"forestgreen": (34, 139, 34),
"darkgreen": (0, 100, 0),
"seagreen": (46, 139, 87),
"mediumseagreen": (60, 179, 113),
"springgreen": (0, 255, 127),
"mediumspringgreen": (0, 250, 154),
"lightgreen": (144, 238, 144),
"palegreen": (152, 251, 152),
"darkseagreen": (143, 188, 143),
"greenyellow": (173, 255, 47),
"chartreuse": (127, 255, 0),
"lawngreen": (124, 252, 0),
"olivedrab": (107, 142, 35),
"olive": (128, 128, 0),
"darkolivegreen": (85, 107, 47),
"yellowgreen": (154, 205, 50),
# Cyans/Teals
"aqua": (0, 255, 255),
"teal": (0, 128, 128),
"darkcyan": (0, 139, 139),
"lightcyan": (224, 255, 255),
"aquamarine": (127, 255, 212),
"mediumaquamarine": (102, 205, 170),
"paleturquoise": (175, 238, 238),
"turquoise": (64, 224, 208),
"mediumturquoise": (72, 209, 204),
"darkturquoise": (0, 206, 209),
"cadetblue": (95, 158, 160),
# Blues
"navy": (0, 0, 128),
"darkblue": (0, 0, 139),
"mediumblue": (0, 0, 205),
"royalblue": (65, 105, 225),
"cornflowerblue": (100, 149, 237),
"steelblue": (70, 130, 180),
"dodgerblue": (30, 144, 255),
"deepskyblue": (0, 191, 255),
"lightskyblue": (135, 206, 250),
"skyblue": (135, 206, 235),
"lightsteelblue": (176, 196, 222),
"lightblue": (173, 216, 230),
"powderblue": (176, 224, 230),
"slateblue": (106, 90, 205),
"mediumslateblue": (123, 104, 238),
"darkslateblue": (72, 61, 139),
"midnightblue": (25, 25, 112),
# Purples/Violets
"purple": (128, 0, 128),
"darkmagenta": (139, 0, 139),
"darkviolet": (148, 0, 211),
"blueviolet": (138, 43, 226),
"darkorchid": (153, 50, 204),
"mediumorchid": (186, 85, 211),
"orchid": (218, 112, 214),
"violet": (238, 130, 238),
"plum": (221, 160, 221),
"thistle": (216, 191, 216),
"lavender": (230, 230, 250),
"indigo": (75, 0, 130),
"mediumpurple": (147, 112, 219),
"fuchsia": (255, 0, 255),
"hotpink": (255, 105, 180),
"deeppink": (255, 20, 147),
"mediumvioletred": (199, 21, 133),
"palevioletred": (219, 112, 147),
# Pinks
"pink": (255, 192, 203),
"lightpink": (255, 182, 193),
"mistyrose": (255, 228, 225),
# Browns
"brown": (165, 42, 42),
"maroon": (128, 0, 0),
"saddlebrown": (139, 69, 19),
"sienna": (160, 82, 45),
"chocolate": (210, 105, 30),
"peru": (205, 133, 63),
"sandybrown": (244, 164, 96),
"burlywood": (222, 184, 135),
"tan": (210, 180, 140),
"rosybrown": (188, 143, 143),
"goldenrod": (218, 165, 32),
"darkgoldenrod": (184, 134, 11),
# Whites
"snow": (255, 250, 250),
"honeydew": (240, 255, 240),
"mintcream": (245, 255, 250),
"azure": (240, 255, 255),
"aliceblue": (240, 248, 255),
"ghostwhite": (248, 248, 255),
"whitesmoke": (245, 245, 245),
"seashell": (255, 245, 238),
"beige": (245, 245, 220),
"oldlace": (253, 245, 230),
"floralwhite": (255, 250, 240),
"ivory": (255, 255, 240),
"antiquewhite": (250, 235, 215),
"linen": (250, 240, 230),
"lavenderblush": (255, 240, 245),
"wheat": (245, 222, 179),
"cornsilk": (255, 248, 220),
"blanchedalmond": (255, 235, 205),
"bisque": (255, 228, 196),
"navajowhite": (255, 222, 173),
# Special
"transparent": (0, 0, 0), # Note: no alpha support, just black
}
def parse_color(color_spec: str) -> Optional[Tuple[int, int, int]]:
"""
Parse a color specification into RGB tuple.
Supports:
- Named colors: "red", "green", "lime", "navy", etc.
- Hex colors: "#FF0000", "#f00", "0xFF0000"
- Special modes: "color", "mono", "invert" return None (handled separately)
Returns:
RGB tuple (r, g, b) or None for special modes
"""
if color_spec is None:
return None
color_spec = str(color_spec).strip().lower()
# Special modes handled elsewhere
if color_spec in ("color", "mono", "invert"):
return None
# Check named colors
if color_spec in NAMED_COLORS:
return NAMED_COLORS[color_spec]
# Handle hex colors
hex_str = None
if color_spec.startswith("#"):
hex_str = color_spec[1:]
elif color_spec.startswith("0x"):
hex_str = color_spec[2:]
elif all(c in "0123456789abcdef" for c in color_spec) and len(color_spec) in (3, 6):
hex_str = color_spec
if hex_str:
try:
if len(hex_str) == 3:
# Short form: #RGB -> #RRGGBB
r = int(hex_str[0] * 2, 16)
g = int(hex_str[1] * 2, 16)
b = int(hex_str[2] * 2, 16)
return (r, g, b)
elif len(hex_str) == 6:
r = int(hex_str[0:2], 16)
g = int(hex_str[2:4], 16)
b = int(hex_str[4:6], 16)
return (r, g, b)
except ValueError:
pass
# Unknown color - default to None (will use original colors)
return None
# =============================================================================
# Image Primitives
# =============================================================================
def prim_width(img: np.ndarray) -> int:
"""Get image width."""
return img.shape[1]
def prim_height(img: np.ndarray) -> int:
"""Get image height."""
return img.shape[0]
def prim_make_image(w: int, h: int, color: List[int]) -> np.ndarray:
"""Create a new image filled with color."""
img = np.zeros((int(h), int(w), 3), dtype=np.uint8)
if color:
img[:, :] = color[:3]
return img
def prim_copy(img: np.ndarray) -> np.ndarray:
"""Copy an image."""
return img.copy()
def prim_pixel(img: np.ndarray, x: int, y: int) -> List[int]:
"""Get pixel at (x, y) as [r, g, b]."""
h, w = img.shape[:2]
x, y = int(x), int(y)
if 0 <= x < w and 0 <= y < h:
return list(img[y, x])
return [0, 0, 0]
def prim_set_pixel(img: np.ndarray, x: int, y: int, color: List[int]) -> np.ndarray:
"""Set pixel at (x, y). Returns modified image."""
h, w = img.shape[:2]
x, y = int(x), int(y)
if 0 <= x < w and 0 <= y < h:
img[y, x] = color[:3]
return img
def prim_sample(img: np.ndarray, x: float, y: float) -> List[float]:
"""Bilinear sample at float coordinates."""
h, w = img.shape[:2]
x = np.clip(x, 0, w - 1)
y = np.clip(y, 0, h - 1)
x0, y0 = int(x), int(y)
x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)
fx, fy = x - x0, y - y0
c00 = img[y0, x0].astype(float)
c10 = img[y0, x1].astype(float)
c01 = img[y1, x0].astype(float)
c11 = img[y1, x1].astype(float)
c = (c00 * (1 - fx) * (1 - fy) +
c10 * fx * (1 - fy) +
c01 * (1 - fx) * fy +
c11 * fx * fy)
return list(c)
def prim_channel(img: np.ndarray, c: int) -> np.ndarray:
"""Extract a single channel as 2D array."""
return img[:, :, int(c)].copy()
def prim_merge_channels(r: np.ndarray, g: np.ndarray, b: np.ndarray) -> np.ndarray:
"""Merge three channels into RGB image."""
return np.stack([r, g, b], axis=-1).astype(np.uint8)
def prim_resize(img: np.ndarray, w: int, h: int, mode: str = "linear") -> np.ndarray:
"""Resize image. Mode: linear, nearest, area."""
w, h = int(w), int(h)
if w < 1 or h < 1:
return img
interp = {
"linear": cv2.INTER_LINEAR,
"nearest": cv2.INTER_NEAREST,
"area": cv2.INTER_AREA,
}.get(mode, cv2.INTER_LINEAR)
return cv2.resize(img, (w, h), interpolation=interp)
def prim_crop(img: np.ndarray, x: int, y: int, w: int, h: int) -> np.ndarray:
"""Crop a region from image."""
ih, iw = img.shape[:2]
x, y, w, h = int(x), int(y), int(w), int(h)
x = max(0, min(x, iw))
y = max(0, min(y, ih))
w = max(0, min(w, iw - x))
h = max(0, min(h, ih - y))
return img[y:y + h, x:x + w].copy()
def prim_paste(dst: np.ndarray, src: np.ndarray, x: int, y: int) -> np.ndarray:
"""Paste src onto dst at position (x, y)."""
dh, dw = dst.shape[:2]
sh, sw = src.shape[:2]
x, y = int(x), int(y)
# Calculate valid regions
sx1 = max(0, -x)
sy1 = max(0, -y)
sx2 = min(sw, dw - x)
sy2 = min(sh, dh - y)
dx1 = max(0, x)
dy1 = max(0, y)
dx2 = dx1 + (sx2 - sx1)
dy2 = dy1 + (sy2 - sy1)
if dx2 > dx1 and dy2 > dy1:
dst[dy1:dy2, dx1:dx2] = src[sy1:sy2, sx1:sx2]
return dst
# =============================================================================
# Color Primitives
# =============================================================================
def prim_rgb(r: float, g: float, b: float) -> List[int]:
"""Create RGB color."""
return [int(np.clip(r, 0, 255)),
int(np.clip(g, 0, 255)),
int(np.clip(b, 0, 255))]
def prim_red(c: List[int]) -> int:
return c[0] if c else 0
def prim_green(c: List[int]) -> int:
return c[1] if len(c) > 1 else 0
def prim_blue(c: List[int]) -> int:
return c[2] if len(c) > 2 else 0
def prim_luminance(c: List[int]) -> float:
"""Calculate luminance (grayscale value)."""
if not c:
return 0
return 0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]
def prim_rgb_to_hsv(c: List[int]) -> List[float]:
"""Convert RGB to HSV."""
r, g, b = c[0] / 255, c[1] / 255, c[2] / 255
mx, mn = max(r, g, b), min(r, g, b)
diff = mx - mn
if diff == 0:
h = 0
elif mx == r:
h = (60 * ((g - b) / diff) + 360) % 360
elif mx == g:
h = (60 * ((b - r) / diff) + 120) % 360
else:
h = (60 * ((r - g) / diff) + 240) % 360
s = 0 if mx == 0 else diff / mx
v = mx
return [h, s * 100, v * 100]
def prim_hsv_to_rgb(hsv: List[float]) -> List[int]:
"""Convert HSV to RGB."""
h, s, v = hsv[0], hsv[1] / 100, hsv[2] / 100
c = v * s
x = c * (1 - abs((h / 60) % 2 - 1))
m = v - c
if h < 60:
r, g, b = c, x, 0
elif h < 120:
r, g, b = x, c, 0
elif h < 180:
r, g, b = 0, c, x
elif h < 240:
r, g, b = 0, x, c
elif h < 300:
r, g, b = x, 0, c
else:
r, g, b = c, 0, x
return [int((r + m) * 255), int((g + m) * 255), int((b + m) * 255)]
def prim_blend_color(c1: List[int], c2: List[int], alpha: float) -> List[int]:
"""Blend two colors."""
alpha = np.clip(alpha, 0, 1)
return [int(c1[i] * (1 - alpha) + c2[i] * alpha) for i in range(3)]
def prim_average_color(img: np.ndarray) -> List[int]:
"""Get average color of image/region."""
return [int(x) for x in img.mean(axis=(0, 1))]
# =============================================================================
# Image Operations (Bulk)
# =============================================================================
def prim_map_pixels(img: np.ndarray, fn: Callable) -> np.ndarray:
"""Apply function to each pixel: fn(x, y, [r,g,b]) -> [r,g,b]."""
result = img.copy()
h, w = img.shape[:2]
for y in range(h):
for x in range(w):
color = list(img[y, x])
new_color = fn(x, y, color)
if new_color is not None:
result[y, x] = new_color[:3]
return result
def prim_map_rows(img: np.ndarray, fn: Callable) -> np.ndarray:
"""Apply function to each row: fn(y, row) -> row."""
result = img.copy()
h = img.shape[0]
for y in range(h):
row = img[y].copy()
new_row = fn(y, row)
if new_row is not None:
result[y] = new_row
return result
def prim_for_grid(img: np.ndarray, cell_size: int, fn: Callable) -> np.ndarray:
"""Iterate over grid cells: fn(gx, gy, cell_img) for side effects."""
cell_size = max(1, int(cell_size))
h, w = img.shape[:2]
rows = h // cell_size
cols = w // cell_size
for gy in range(rows):
for gx in range(cols):
y, x = gy * cell_size, gx * cell_size
cell = img[y:y + cell_size, x:x + cell_size]
fn(gx, gy, cell)
return img
def prim_fold_pixels(img: np.ndarray, init: Any, fn: Callable) -> Any:
"""Fold over pixels: fn(acc, x, y, color) -> acc."""
acc = init
h, w = img.shape[:2]
for y in range(h):
for x in range(w):
color = list(img[y, x])
acc = fn(acc, x, y, color)
return acc
# =============================================================================
# Convolution / Filters
# =============================================================================
def prim_convolve(img: np.ndarray, kernel: List[List[float]]) -> np.ndarray:
"""Apply convolution kernel."""
k = np.array(kernel, dtype=np.float32)
return cv2.filter2D(img, -1, k)
def prim_blur(img: np.ndarray, radius: int) -> np.ndarray:
"""Gaussian blur."""
radius = max(1, int(radius))
ksize = radius * 2 + 1
return cv2.GaussianBlur(img, (ksize, ksize), 0)
def prim_box_blur(img: np.ndarray, radius: int) -> np.ndarray:
"""Box blur (faster than Gaussian)."""
radius = max(1, int(radius))
ksize = radius * 2 + 1
return cv2.blur(img, (ksize, ksize))
def prim_edges(img: np.ndarray, low: int = 50, high: int = 150) -> np.ndarray:
"""Canny edge detection, returns grayscale edges."""
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
edges = cv2.Canny(gray, int(low), int(high))
return cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB)
def prim_sobel(img: np.ndarray) -> np.ndarray:
"""Sobel edge detection."""
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY).astype(np.float32)
sx = cv2.Sobel(gray, cv2.CV_32F, 1, 0)
sy = cv2.Sobel(gray, cv2.CV_32F, 0, 1)
magnitude = np.sqrt(sx ** 2 + sy ** 2)
magnitude = np.clip(magnitude, 0, 255).astype(np.uint8)
return cv2.cvtColor(magnitude, cv2.COLOR_GRAY2RGB)
def prim_dilate(img: np.ndarray, size: int = 1) -> np.ndarray:
"""Morphological dilation."""
kernel = np.ones((size, size), np.uint8)
return cv2.dilate(img, kernel, iterations=1)
def prim_erode(img: np.ndarray, size: int = 1) -> np.ndarray:
"""Morphological erosion."""
kernel = np.ones((size, size), np.uint8)
return cv2.erode(img, kernel, iterations=1)
# =============================================================================
# Geometric Transforms
# =============================================================================
def prim_translate(img: np.ndarray, dx: float, dy: float) -> np.ndarray:
"""Translate image."""
h, w = img.shape[:2]
M = np.float32([[1, 0, dx], [0, 1, dy]])
return cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_REFLECT)
def prim_rotate(img: np.ndarray, angle: float, cx: float = None, cy: float = None) -> np.ndarray:
"""Rotate image around center."""
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
return cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_REFLECT)
def prim_scale(img: np.ndarray, sx: float, sy: float, cx: float = None, cy: float = None) -> np.ndarray:
"""Scale image around center."""
h, w = img.shape[:2]
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
M = np.float32([
[sx, 0, cx * (1 - sx)],
[0, sy, cy * (1 - sy)]
])
return cv2.warpAffine(img, M, (w, h), borderMode=cv2.BORDER_REFLECT)
def prim_flip_h(img: np.ndarray) -> np.ndarray:
"""Flip horizontally."""
return cv2.flip(img, 1)
def prim_flip_v(img: np.ndarray) -> np.ndarray:
"""Flip vertically."""
return cv2.flip(img, 0)
def prim_remap(img: np.ndarray, map_x: np.ndarray, map_y: np.ndarray) -> np.ndarray:
"""Remap using coordinate maps."""
return cv2.remap(img, map_x.astype(np.float32), map_y.astype(np.float32),
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
def prim_make_coords(w: int, h: int) -> Tuple[np.ndarray, np.ndarray]:
"""Create coordinate grid (map_x, map_y)."""
map_x = np.tile(np.arange(w, dtype=np.float32), (h, 1))
map_y = np.tile(np.arange(h, dtype=np.float32).reshape(-1, 1), (1, w))
return map_x, map_y
# =============================================================================
# Blending
# =============================================================================
def prim_blend_images(a: np.ndarray, b: np.ndarray, alpha: float) -> np.ndarray:
"""Blend two images. Auto-resizes b to match a if sizes differ."""
alpha = np.clip(alpha, 0, 1)
# Auto-resize b to match a if different sizes
if a.shape[:2] != b.shape[:2]:
b = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_LINEAR)
return (a.astype(float) * (1 - alpha) + b.astype(float) * alpha).astype(np.uint8)
def prim_blend_mode(a: np.ndarray, b: np.ndarray, mode: str) -> np.ndarray:
"""Blend with various modes: add, multiply, screen, overlay, difference.
Auto-resizes b to match a if sizes differ."""
# Auto-resize b to match a if different sizes
if a.shape[:2] != b.shape[:2]:
b = cv2.resize(b, (a.shape[1], a.shape[0]), interpolation=cv2.INTER_LINEAR)
af = a.astype(float) / 255
bf = b.astype(float) / 255
if mode == "add":
result = af + bf
elif mode == "multiply":
result = af * bf
elif mode == "screen":
result = 1 - (1 - af) * (1 - bf)
elif mode == "overlay":
mask = af < 0.5
result = np.where(mask, 2 * af * bf, 1 - 2 * (1 - af) * (1 - bf))
elif mode == "difference":
result = np.abs(af - bf)
elif mode == "lighten":
result = np.maximum(af, bf)
elif mode == "darken":
result = np.minimum(af, bf)
else:
result = af
return (np.clip(result, 0, 1) * 255).astype(np.uint8)
def prim_mask(img: np.ndarray, mask_img: np.ndarray) -> np.ndarray:
"""Apply grayscale mask to image."""
if len(mask_img.shape) == 3:
mask = cv2.cvtColor(mask_img, cv2.COLOR_RGB2GRAY)
else:
mask = mask_img
mask_f = mask.astype(float) / 255
result = img.astype(float) * mask_f[:, :, np.newaxis]
return result.astype(np.uint8)
# =============================================================================
# Drawing
# =============================================================================
# Simple font (5x7 bitmap characters)
FONT_5X7 = {
' ': [0, 0, 0, 0, 0, 0, 0],
'.': [0, 0, 0, 0, 0, 0, 4],
':': [0, 0, 4, 0, 4, 0, 0],
'-': [0, 0, 0, 14, 0, 0, 0],
'=': [0, 0, 14, 0, 14, 0, 0],
'+': [0, 4, 4, 31, 4, 4, 0],
'*': [0, 4, 21, 14, 21, 4, 0],
'#': [10, 31, 10, 10, 31, 10, 0],
'%': [19, 19, 4, 8, 25, 25, 0],
'@': [14, 17, 23, 21, 23, 16, 14],
'0': [14, 17, 19, 21, 25, 17, 14],
'1': [4, 12, 4, 4, 4, 4, 14],
'2': [14, 17, 1, 2, 4, 8, 31],
'3': [31, 2, 4, 2, 1, 17, 14],
'4': [2, 6, 10, 18, 31, 2, 2],
'5': [31, 16, 30, 1, 1, 17, 14],
'6': [6, 8, 16, 30, 17, 17, 14],
'7': [31, 1, 2, 4, 8, 8, 8],
'8': [14, 17, 17, 14, 17, 17, 14],
'9': [14, 17, 17, 15, 1, 2, 12],
}
# Add uppercase letters
for i, c in enumerate('ABCDEFGHIJKLMNOPQRSTUVWXYZ'):
FONT_5X7[c] = [0] * 7 # Placeholder
def prim_draw_char(img: np.ndarray, char: str, x: int, y: int,
size: int, color: List[int]) -> np.ndarray:
"""Draw a character at position."""
# Use OpenCV's built-in font for simplicity
font = cv2.FONT_HERSHEY_SIMPLEX
scale = size / 20.0
thickness = max(1, int(size / 10))
cv2.putText(img, char, (int(x), int(y + size)), font, scale, tuple(color[:3]), thickness)
return img
def prim_draw_text(img: np.ndarray, text: str, x: int, y: int,
size: int, color: List[int]) -> np.ndarray:
"""Draw text at position."""
font = cv2.FONT_HERSHEY_SIMPLEX
scale = size / 20.0
thickness = max(1, int(size / 10))
cv2.putText(img, text, (int(x), int(y + size)), font, scale, tuple(color[:3]), thickness)
return img
def prim_fill_rect(img: np.ndarray, x: int, y: int, w: int, h: int,
color: List[int]) -> np.ndarray:
"""Fill rectangle."""
x, y, w, h = int(x), int(y), int(w), int(h)
img[y:y + h, x:x + w] = color[:3]
return img
def prim_draw_line(img: np.ndarray, x1: int, y1: int, x2: int, y2: int,
color: List[int], thickness: int = 1) -> np.ndarray:
"""Draw line."""
cv2.line(img, (int(x1), int(y1)), (int(x2), int(y2)), tuple(color[:3]), int(thickness))
return img
# =============================================================================
# Math Primitives
# =============================================================================
def prim_sin(x: float) -> float:
return math.sin(x)
def prim_cos(x: float) -> float:
return math.cos(x)
def prim_tan(x: float) -> float:
return math.tan(x)
def prim_atan2(y: float, x: float) -> float:
return math.atan2(y, x)
def prim_sqrt(x: float) -> float:
return math.sqrt(max(0, x))
def prim_pow(x: float, y: float) -> float:
return math.pow(x, y)
def prim_abs(x: float) -> float:
return abs(x)
def prim_floor(x: float) -> int:
return int(math.floor(x))
def prim_ceil(x: float) -> int:
return int(math.ceil(x))
def prim_round(x: float) -> int:
return int(round(x))
def prim_min(*args) -> float:
return min(args)
def prim_max(*args) -> float:
return max(args)
def prim_clamp(x: float, lo: float, hi: float) -> float:
return max(lo, min(hi, x))
def prim_lerp(a: float, b: float, t: float) -> float:
"""Linear interpolation."""
return a + (b - a) * t
def prim_mod(a: float, b: float) -> float:
return a % b
def prim_random(lo: float = 0, hi: float = 1) -> float:
"""Random number from global RNG."""
return _rng.random(lo, hi)
def prim_randint(lo: int, hi: int) -> int:
"""Random integer from global RNG."""
return _rng.randint(lo, hi)
def prim_gaussian(mean: float = 0, std: float = 1) -> float:
"""Gaussian random from global RNG."""
return _rng.gaussian(mean, std)
def prim_assert(condition, message: str = "Assertion failed"):
"""Assert that condition is true, raise error with message if false."""
if not condition:
raise RuntimeError(f"Assertion error: {message}")
return True
# =============================================================================
# Array/List Primitives
# =============================================================================
def prim_length(seq) -> int:
return len(seq)
def prim_nth(seq, i: int):
i = int(i)
if 0 <= i < len(seq):
return seq[i]
return None
def prim_first(seq):
return seq[0] if seq else None
def prim_rest(seq):
return seq[1:] if seq else []
def prim_take(seq, n: int):
return seq[:int(n)]
def prim_drop(seq, n: int):
return seq[int(n):]
def prim_cons(x, seq):
return [x] + list(seq)
def prim_append(*seqs):
result = []
for s in seqs:
result.extend(s)
return result
def prim_reverse(seq):
return list(reversed(seq))
def prim_range(start: int, end: int, step: int = 1) -> List[int]:
return list(range(int(start), int(end), int(step)))
def prim_roll(arr: np.ndarray, shift: int, axis: int = 0) -> np.ndarray:
"""Circular roll of array."""
return np.roll(arr, int(shift), axis=int(axis))
def prim_list(*args) -> list:
"""Create a list."""
return list(args)
# =============================================================================
# Primitive Registry
# =============================================================================
def prim_add(*args):
return sum(args)
def prim_sub(a, b=None):
if b is None:
return -a # Unary negation
return a - b
def prim_mul(*args):
result = 1
for x in args:
result *= x
return result
def prim_div(a, b):
return a / b if b != 0 else 0
def prim_lt(a, b):
return a < b
def prim_gt(a, b):
return a > b
def prim_le(a, b):
return a <= b
def prim_ge(a, b):
return a >= b
def prim_eq(a, b):
# Handle None/nil comparisons with numpy arrays
if a is None:
return b is None
if b is None:
return a is None
if isinstance(a, np.ndarray) or isinstance(b, np.ndarray):
if isinstance(a, np.ndarray) and isinstance(b, np.ndarray):
return np.array_equal(a, b)
return False # array vs non-array
return a == b
def prim_ne(a, b):
return not prim_eq(a, b)
# =============================================================================
# Vectorized Bulk Operations (true primitives for composing effects)
# =============================================================================
def prim_color_matrix(img: np.ndarray, matrix: List[List[float]]) -> np.ndarray:
"""Apply a 3x3 color transformation matrix to all pixels."""
m = np.array(matrix, dtype=np.float32)
result = img.astype(np.float32) @ m.T
return np.clip(result, 0, 255).astype(np.uint8)
def prim_adjust(img: np.ndarray, brightness: float = 0, contrast: float = 1) -> np.ndarray:
"""Adjust brightness and contrast. Brightness: -255 to 255, Contrast: 0 to 3+."""
result = (img.astype(np.float32) - 128) * contrast + 128 + brightness
return np.clip(result, 0, 255).astype(np.uint8)
def prim_mix_gray(img: np.ndarray, amount: float) -> np.ndarray:
"""Mix image with its grayscale version. 0=original, 1=grayscale."""
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
gray_rgb = np.stack([gray, gray, gray], axis=-1)
result = img.astype(np.float32) * (1 - amount) + gray_rgb * amount
return np.clip(result, 0, 255).astype(np.uint8)
def prim_invert_img(img: np.ndarray) -> np.ndarray:
"""Invert all pixel values."""
return (255 - img).astype(np.uint8)
def prim_add_noise(img: np.ndarray, amount: float) -> np.ndarray:
"""Add gaussian noise to image."""
noise = _rng._rng.normal(0, amount, img.shape)
result = img.astype(np.float32) + noise
return np.clip(result, 0, 255).astype(np.uint8)
def prim_quantize(img: np.ndarray, levels: int) -> np.ndarray:
"""Reduce to N color levels per channel."""
levels = max(2, int(levels))
factor = 256 / levels
result = (img // factor) * factor + factor // 2
return np.clip(result, 0, 255).astype(np.uint8)
def prim_shift_hsv(img: np.ndarray, h: float = 0, s: float = 1, v: float = 1) -> np.ndarray:
"""Shift HSV: h=degrees offset, s/v=multipliers."""
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
hsv[:, :, 0] = (hsv[:, :, 0] + h / 2) % 180
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * s, 0, 255)
hsv[:, :, 2] = np.clip(hsv[:, :, 2] * v, 0, 255)
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
# =============================================================================
# Array Math Primitives (vectorized operations on coordinate arrays)
# =============================================================================
def prim_arr_add(a: np.ndarray, b) -> np.ndarray:
"""Element-wise addition. b can be array or scalar."""
return (np.asarray(a) + np.asarray(b)).astype(np.float32)
def prim_arr_sub(a: np.ndarray, b) -> np.ndarray:
"""Element-wise subtraction. b can be array or scalar."""
return (np.asarray(a) - np.asarray(b)).astype(np.float32)
def prim_arr_mul(a: np.ndarray, b) -> np.ndarray:
"""Element-wise multiplication. b can be array or scalar."""
return (np.asarray(a) * np.asarray(b)).astype(np.float32)
def prim_arr_div(a: np.ndarray, b) -> np.ndarray:
"""Element-wise division. b can be array or scalar."""
b = np.asarray(b)
# Avoid division by zero
with np.errstate(divide='ignore', invalid='ignore'):
result = np.asarray(a) / np.where(b == 0, 1e-10, b)
return result.astype(np.float32)
def prim_arr_mod(a: np.ndarray, b) -> np.ndarray:
"""Element-wise modulo."""
return (np.asarray(a) % np.asarray(b)).astype(np.float32)
def prim_arr_sin(a: np.ndarray) -> np.ndarray:
"""Element-wise sine."""
return np.sin(np.asarray(a)).astype(np.float32)
def prim_arr_cos(a: np.ndarray) -> np.ndarray:
"""Element-wise cosine."""
return np.cos(np.asarray(a)).astype(np.float32)
def prim_arr_tan(a: np.ndarray) -> np.ndarray:
"""Element-wise tangent."""
return np.tan(np.asarray(a)).astype(np.float32)
def prim_arr_sqrt(a: np.ndarray) -> np.ndarray:
"""Element-wise square root."""
return np.sqrt(np.maximum(0, np.asarray(a))).astype(np.float32)
def prim_arr_pow(a: np.ndarray, b) -> np.ndarray:
"""Element-wise power."""
return np.power(np.asarray(a), np.asarray(b)).astype(np.float32)
def prim_arr_abs(a: np.ndarray) -> np.ndarray:
"""Element-wise absolute value."""
return np.abs(np.asarray(a)).astype(np.float32)
def prim_arr_neg(a: np.ndarray) -> np.ndarray:
"""Element-wise negation."""
return (-np.asarray(a)).astype(np.float32)
def prim_arr_exp(a: np.ndarray) -> np.ndarray:
"""Element-wise exponential."""
return np.exp(np.asarray(a)).astype(np.float32)
def prim_arr_atan2(y: np.ndarray, x: np.ndarray) -> np.ndarray:
"""Element-wise atan2(y, x)."""
return np.arctan2(np.asarray(y), np.asarray(x)).astype(np.float32)
def prim_arr_min(a: np.ndarray, b) -> np.ndarray:
"""Element-wise minimum."""
return np.minimum(np.asarray(a), np.asarray(b)).astype(np.float32)
def prim_arr_max(a: np.ndarray, b) -> np.ndarray:
"""Element-wise maximum."""
return np.maximum(np.asarray(a), np.asarray(b)).astype(np.float32)
def prim_arr_clip(a: np.ndarray, lo, hi) -> np.ndarray:
"""Element-wise clip to range."""
return np.clip(np.asarray(a), lo, hi).astype(np.float32)
def prim_arr_where(cond: np.ndarray, a, b) -> np.ndarray:
"""Element-wise conditional: where cond is true, use a, else b."""
return np.where(np.asarray(cond), np.asarray(a), np.asarray(b)).astype(np.float32)
def prim_arr_floor(a: np.ndarray) -> np.ndarray:
"""Element-wise floor."""
return np.floor(np.asarray(a)).astype(np.float32)
def prim_arr_lerp(a: np.ndarray, b: np.ndarray, t) -> np.ndarray:
"""Element-wise linear interpolation."""
a, b = np.asarray(a), np.asarray(b)
return (a + (b - a) * t).astype(np.float32)
# =============================================================================
# Coordinate Transformation Primitives
# =============================================================================
def prim_polar_from_center(img_or_w, h_or_cx=None, cx=None, cy=None) -> Tuple[np.ndarray, np.ndarray]:
"""
Create polar coordinates (r, theta) from image center.
Usage:
(polar-from-center img) ; center of image
(polar-from-center img cx cy) ; custom center
(polar-from-center w h cx cy) ; explicit dimensions
Returns: (r, theta) tuple of arrays
"""
if isinstance(img_or_w, np.ndarray):
h, w = img_or_w.shape[:2]
if h_or_cx is None:
cx, cy = w / 2, h / 2
else:
cx, cy = h_or_cx, cx if cx is not None else h / 2
else:
w = int(img_or_w)
h = int(h_or_cx)
cx = cx if cx is not None else w / 2
cy = cy if cy is not None else h / 2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
dx = x_coords - cx
dy = y_coords - cy
r = np.sqrt(dx**2 + dy**2)
theta = np.arctan2(dy, dx)
return (r, theta)
def prim_cart_from_polar(r: np.ndarray, theta: np.ndarray, cx: float, cy: float) -> Tuple[np.ndarray, np.ndarray]:
"""
Convert polar coordinates back to Cartesian.
Args:
r: radius array
theta: angle array
cx, cy: center point
Returns: (x, y) tuple of coordinate arrays
"""
x = (cx + r * np.cos(theta)).astype(np.float32)
y = (cy + r * np.sin(theta)).astype(np.float32)
return (x, y)
def prim_normalize_coords(img_or_w, h_or_cx=None, cx=None, cy=None) -> Tuple[np.ndarray, np.ndarray]:
"""
Create normalized coordinates (-1 to 1) from center.
Returns: (x_norm, y_norm) tuple of arrays where center is (0,0)
"""
if isinstance(img_or_w, np.ndarray):
h, w = img_or_w.shape[:2]
if h_or_cx is None:
cx, cy = w / 2, h / 2
else:
cx, cy = h_or_cx, cx if cx is not None else h / 2
else:
w = int(img_or_w)
h = int(h_or_cx)
cx = cx if cx is not None else w / 2
cy = cy if cy is not None else h / 2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
x_norm = (x_coords - cx) / (w / 2)
y_norm = (y_coords - cy) / (h / 2)
return (x_norm, y_norm)
def prim_coords_x(coords: Tuple[np.ndarray, np.ndarray]) -> np.ndarray:
"""Get x/first component from coordinate tuple."""
return coords[0]
def prim_coords_y(coords: Tuple[np.ndarray, np.ndarray]) -> np.ndarray:
"""Get y/second component from coordinate tuple."""
return coords[1]
def prim_make_coords_centered(w: int, h: int, cx: float = None, cy: float = None) -> Tuple[np.ndarray, np.ndarray]:
"""
Create coordinate grids centered at (cx, cy).
Like make-coords but returns coordinates relative to center.
"""
w, h = int(w), int(h)
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
return (x_coords - cx, y_coords - cy)
# =============================================================================
# Specialized Distortion Primitives
# =============================================================================
def prim_wave_displace(w: int, h: int, axis: str, freq: float, amp: float, phase: float = 0) -> Tuple[np.ndarray, np.ndarray]:
"""
Create wave displacement maps.
Args:
w, h: dimensions
axis: "x" (horizontal waves) or "y" (vertical waves)
freq: wave frequency (waves per image width/height)
amp: wave amplitude in pixels
phase: phase offset in radians
Returns: (map_x, map_y) for use with remap
"""
w, h = int(w), int(h)
map_x = np.tile(np.arange(w, dtype=np.float32), (h, 1))
map_y = np.tile(np.arange(h, dtype=np.float32).reshape(-1, 1), (1, w))
if axis == "x" or axis == "horizontal":
# Horizontal waves: displace x based on y
wave = np.sin(2 * np.pi * freq * map_y / h + phase) * amp
map_x = map_x + wave
elif axis == "y" or axis == "vertical":
# Vertical waves: displace y based on x
wave = np.sin(2 * np.pi * freq * map_x / w + phase) * amp
map_y = map_y + wave
elif axis == "both":
wave_x = np.sin(2 * np.pi * freq * map_y / h + phase) * amp
wave_y = np.sin(2 * np.pi * freq * map_x / w + phase) * amp
map_x = map_x + wave_x
map_y = map_y + wave_y
return (map_x, map_y)
def prim_swirl_displace(w: int, h: int, strength: float, radius: float = 0.5,
cx: float = None, cy: float = None, falloff: str = "quadratic") -> Tuple[np.ndarray, np.ndarray]:
"""
Create swirl displacement maps.
Args:
w, h: dimensions
strength: swirl strength in radians
radius: effect radius as fraction of max dimension
cx, cy: center (defaults to image center)
falloff: "linear", "quadratic", or "gaussian"
Returns: (map_x, map_y) for use with remap
"""
w, h = int(w), int(h)
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
radius_px = max(w, h) * radius
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
dx = x_coords - cx
dy = y_coords - cy
dist = np.sqrt(dx**2 + dy**2)
angle = np.arctan2(dy, dx)
# Normalized distance for falloff
norm_dist = dist / radius_px
# Calculate falloff factor
if falloff == "linear":
factor = np.maximum(0, 1 - norm_dist)
elif falloff == "gaussian":
factor = np.exp(-norm_dist**2 * 2)
else: # quadratic
factor = np.maximum(0, 1 - norm_dist**2)
# Apply swirl rotation
new_angle = angle + strength * factor
# Calculate new coordinates
map_x = (cx + dist * np.cos(new_angle)).astype(np.float32)
map_y = (cy + dist * np.sin(new_angle)).astype(np.float32)
return (map_x, map_y)
def prim_fisheye_displace(w: int, h: int, strength: float, cx: float = None, cy: float = None,
zoom_correct: bool = True) -> Tuple[np.ndarray, np.ndarray]:
"""
Create fisheye/barrel distortion displacement maps.
Args:
w, h: dimensions
strength: distortion strength (-1 to 1, positive=bulge, negative=pinch)
cx, cy: center (defaults to image center)
zoom_correct: auto-zoom to hide black edges
Returns: (map_x, map_y) for use with remap
"""
w, h = int(w), int(h)
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
# Normalize coordinates
x_norm = (x_coords - cx) / (w / 2)
y_norm = (y_coords - cy) / (h / 2)
r = np.sqrt(x_norm**2 + y_norm**2)
# Apply barrel/pincushion distortion
if strength > 0:
r_distorted = r * (1 + strength * r**2)
else:
r_distorted = r / (1 - strength * r**2 + 0.001)
# Calculate scale factor
with np.errstate(divide='ignore', invalid='ignore'):
scale = np.where(r > 0, r_distorted / r, 1)
# Apply zoom correction
if zoom_correct and strength > 0:
zoom = 1 + strength * 0.5
scale = scale / zoom
# Calculate new coordinates
map_x = (x_norm * scale * (w / 2) + cx).astype(np.float32)
map_y = (y_norm * scale * (h / 2) + cy).astype(np.float32)
return (map_x, map_y)
def prim_kaleidoscope_displace(w: int, h: int, segments: int, rotation: float = 0,
cx: float = None, cy: float = None, zoom: float = 1.0) -> Tuple[np.ndarray, np.ndarray]:
"""
Create kaleidoscope displacement maps.
Args:
w, h: dimensions
segments: number of symmetry segments (3-16)
rotation: rotation angle in degrees
cx, cy: center (defaults to image center)
zoom: zoom factor
Returns: (map_x, map_y) for use with remap
"""
w, h = int(w), int(h)
segments = max(3, min(int(segments), 16))
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
segment_angle = 2 * np.pi / segments
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
# Translate to center
x_centered = x_coords - cx
y_centered = y_coords - cy
# Convert to polar
r = np.sqrt(x_centered**2 + y_centered**2)
theta = np.arctan2(y_centered, x_centered)
# Apply rotation
theta = theta - np.deg2rad(rotation)
# Fold angle into first segment and mirror
theta_normalized = theta % (2 * np.pi)
segment_idx = (theta_normalized / segment_angle).astype(int)
theta_in_segment = theta_normalized - segment_idx * segment_angle
# Mirror alternating segments
mirror_mask = (segment_idx % 2) == 1
theta_in_segment = np.where(mirror_mask, segment_angle - theta_in_segment, theta_in_segment)
# Apply zoom
r = r / zoom
# Convert back to Cartesian
map_x = (r * np.cos(theta_in_segment) + cx).astype(np.float32)
map_y = (r * np.sin(theta_in_segment) + cy).astype(np.float32)
return (map_x, map_y)
# =============================================================================
# Character/ASCII Art Primitives
# =============================================================================
# Character sets ordered by visual density (light to dark)
CHAR_ALPHABETS = {
"standard": " .`'^\",:;Il!i><~+_-?][}{1)(|/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
"blocks": " ░▒▓█",
"simple": " .-:=+*#%@",
"digits": " 0123456789",
}
# Global atlas cache: keyed on (frozenset(chars), cell_size) ->
# (atlas_array, char_to_idx) where atlas_array is (N, cell_size, cell_size) uint8.
_char_atlas_cache = {}
_CHAR_ATLAS_CACHE_MAX = 32
def _get_char_atlas(alphabet: str, cell_size: int) -> dict:
"""Get or create character atlas for alphabet (legacy dict version)."""
atlas_arr, char_to_idx = _get_render_atlas(alphabet, cell_size)
# Build legacy dict from array
idx_to_char = {v: k for k, v in char_to_idx.items()}
return {idx_to_char[i]: atlas_arr[i] for i in range(len(atlas_arr))}
def _get_render_atlas(unique_chars_or_alphabet, cell_size: int):
"""Get or build a stacked numpy atlas for vectorised rendering.
Args:
unique_chars_or_alphabet: Either an alphabet name (str looked up in
CHAR_ALPHABETS), a literal character string, or a set/frozenset
of characters.
cell_size: Pixel size of each cell.
Returns:
(atlas_array, char_to_idx) where
atlas_array: (num_chars, cell_size, cell_size) uint8 masks
char_to_idx: dict mapping character -> index in atlas_array
"""
if isinstance(unique_chars_or_alphabet, (set, frozenset)):
chars_tuple = tuple(sorted(unique_chars_or_alphabet))
else:
resolved = CHAR_ALPHABETS.get(unique_chars_or_alphabet, unique_chars_or_alphabet)
chars_tuple = tuple(resolved)
cache_key = (chars_tuple, cell_size)
cached = _char_atlas_cache.get(cache_key)
if cached is not None:
return cached
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = cell_size / 20.0
thickness = max(1, int(cell_size / 10))
n = len(chars_tuple)
atlas = np.zeros((n, cell_size, cell_size), dtype=np.uint8)
char_to_idx = {}
for i, char in enumerate(chars_tuple):
char_to_idx[char] = i
if char and char != ' ':
try:
(text_w, text_h), _ = cv2.getTextSize(char, font, font_scale, thickness)
text_x = max(0, (cell_size - text_w) // 2)
text_y = (cell_size + text_h) // 2
cv2.putText(atlas[i], char, (text_x, text_y),
font, font_scale, 255, thickness, cv2.LINE_AA)
except Exception:
pass
# Evict oldest entry if cache is full
if len(_char_atlas_cache) >= _CHAR_ATLAS_CACHE_MAX:
_char_atlas_cache.pop(next(iter(_char_atlas_cache)))
_char_atlas_cache[cache_key] = (atlas, char_to_idx)
return atlas, char_to_idx
def prim_cell_sample(img: np.ndarray, cell_size: int) -> Tuple[np.ndarray, np.ndarray]:
"""
Sample image into cell grid, returning average colors and luminances.
Uses cv2.resize with INTER_AREA (pixel-area averaging) which is
~25x faster than numpy reshape+mean for block downsampling.
Args:
img: source image
cell_size: size of each cell in pixels
Returns: (colors, luminances) tuple
- colors: (rows, cols, 3) array of average RGB per cell
- luminances: (rows, cols) array of average brightness 0-255
"""
cell_size = max(1, int(cell_size))
h, w = img.shape[:2]
rows = h // cell_size
cols = w // cell_size
if rows < 1 or cols < 1:
return (np.zeros((1, 1, 3), dtype=np.uint8),
np.zeros((1, 1), dtype=np.float32))
# Crop to exact grid then block-average via cv2 area interpolation.
grid_h, grid_w = rows * cell_size, cols * cell_size
cropped = img[:grid_h, :grid_w]
colors = cv2.resize(cropped, (cols, rows), interpolation=cv2.INTER_AREA)
# Compute luminance
luminances = (0.299 * colors[:, :, 0] +
0.587 * colors[:, :, 1] +
0.114 * colors[:, :, 2]).astype(np.float32)
return (colors, luminances)
def cell_sample_extended(img: np.ndarray, cell_size: int) -> Tuple[np.ndarray, np.ndarray, List[List[ZoneContext]]]:
"""
Sample image into cell grid, returning colors, luminances, and full zone contexts.
Args:
img: source image (RGB)
cell_size: size of each cell in pixels
Returns: (colors, luminances, zone_contexts) tuple
- colors: (rows, cols, 3) array of average RGB per cell
- luminances: (rows, cols) array of average brightness 0-255
- zone_contexts: 2D list of ZoneContext objects with full cell data
"""
cell_size = max(1, int(cell_size))
h, w = img.shape[:2]
rows = h // cell_size
cols = w // cell_size
if rows < 1 or cols < 1:
return (np.zeros((1, 1, 3), dtype=np.uint8),
np.zeros((1, 1), dtype=np.float32),
[[ZoneContext(0, 0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0)]])
# Crop to grid
grid_h, grid_w = rows * cell_size, cols * cell_size
cropped = img[:grid_h, :grid_w]
# Reshape and average
reshaped = cropped.reshape(rows, cell_size, cols, cell_size, 3)
colors = reshaped.mean(axis=(1, 3)).astype(np.uint8)
# Compute luminance (0-255)
luminances = (0.299 * colors[:, :, 0] +
0.587 * colors[:, :, 1] +
0.114 * colors[:, :, 2]).astype(np.float32)
# Normalize colors to 0-1 for HSV/saturation calculations
colors_float = colors.astype(np.float32) / 255.0
# Compute HSV values for each cell
max_c = colors_float.max(axis=2)
min_c = colors_float.min(axis=2)
diff = max_c - min_c
# Saturation
saturation = np.where(max_c > 0, diff / max_c, 0)
# Hue (0-360)
hue = np.zeros((rows, cols), dtype=np.float32)
# Avoid division by zero
mask = diff > 0
r, g, b = colors_float[:, :, 0], colors_float[:, :, 1], colors_float[:, :, 2]
# Red is max
red_max = mask & (max_c == r)
hue[red_max] = 60 * (((g[red_max] - b[red_max]) / diff[red_max]) % 6)
# Green is max
green_max = mask & (max_c == g)
hue[green_max] = 60 * ((b[green_max] - r[green_max]) / diff[green_max] + 2)
# Blue is max
blue_max = mask & (max_c == b)
hue[blue_max] = 60 * ((r[blue_max] - g[blue_max]) / diff[blue_max] + 4)
# Ensure hue is in 0-360 range
hue = hue % 360
# Build zone contexts
zone_contexts = []
for row in range(rows):
row_contexts = []
for col in range(cols):
ctx = ZoneContext(
row=row,
col=col,
row_norm=row / max(1, rows - 1) if rows > 1 else 0.5,
col_norm=col / max(1, cols - 1) if cols > 1 else 0.5,
luminance=luminances[row, col] / 255.0, # Normalize to 0-1
saturation=float(saturation[row, col]),
hue=float(hue[row, col]),
r=float(colors_float[row, col, 0]),
g=float(colors_float[row, col, 1]),
b=float(colors_float[row, col, 2]),
)
row_contexts.append(ctx)
zone_contexts.append(row_contexts)
return (colors, luminances, zone_contexts)
def prim_luminance_to_chars(luminances: np.ndarray, alphabet: str, contrast: float = 1.0) -> List[List[str]]:
"""
Map luminance values to characters from alphabet.
Args:
luminances: (rows, cols) array of brightness values 0-255
alphabet: character set name or literal string (light to dark)
contrast: contrast boost factor
Returns: 2D list of single-character strings
"""
chars = CHAR_ALPHABETS.get(alphabet, alphabet)
num_chars = len(chars)
# Apply contrast
lum = luminances.astype(np.float32)
if contrast != 1.0:
lum = (lum - 128) * contrast + 128
lum = np.clip(lum, 0, 255)
# Map to indices
indices = ((lum / 255) * (num_chars - 1)).astype(np.int32)
indices = np.clip(indices, 0, num_chars - 1)
# Vectorised conversion via numpy char array lookup
chars_arr = np.array(list(chars))
char_grid = chars_arr[indices.ravel()].reshape(indices.shape)
return char_grid.tolist()
def prim_render_char_grid(img: np.ndarray, chars: List[List[str]], colors: np.ndarray,
cell_size: int, color_mode: str = "color",
background_color: str = "black",
invert_colors: bool = False) -> np.ndarray:
"""
Render a grid of characters onto an image.
Uses vectorised numpy operations instead of per-cell Python loops:
the character atlas is looked up via fancy indexing and the full
mask + colour image are assembled in bulk.
Args:
img: source image (for dimensions)
chars: 2D list of single characters
colors: (rows, cols, 3) array of colors per cell
cell_size: size of each cell
color_mode: "color" (original colors), "mono" (white), "invert",
or any color name/hex value ("green", "lime", "#00ff00")
background_color: background color name/hex ("black", "navy", "#001100")
invert_colors: if True, swap foreground and background colors
Returns: rendered image
"""
# Parse color_mode - may be a named color or hex value
fg_color = parse_color(color_mode)
# Parse background_color
if isinstance(background_color, (list, tuple)):
bg_color = tuple(int(c) for c in background_color[:3])
else:
bg_color = parse_color(background_color)
if bg_color is None:
bg_color = (0, 0, 0)
# Handle invert_colors - swap fg and bg
if invert_colors and fg_color is not None:
fg_color, bg_color = bg_color, fg_color
cell_size = max(1, int(cell_size))
if not chars or not chars[0]:
return img.copy()
rows = len(chars)
cols = len(chars[0])
h, w = rows * cell_size, cols * cell_size
bg = list(bg_color)
# --- Build atlas & index grid ---
unique_chars = set()
for row in chars:
for ch in row:
unique_chars.add(ch)
atlas, char_to_idx = _get_render_atlas(unique_chars, cell_size)
# Convert 2D char list to index array using ordinal lookup table
# (avoids per-cell Python dict lookup).
space_idx = char_to_idx.get(' ', 0)
max_ord = max(ord(ch) for ch in char_to_idx) + 1
ord_lookup = np.full(max_ord, space_idx, dtype=np.int32)
for ch, idx in char_to_idx.items():
if ch:
ord_lookup[ord(ch)] = idx
flat = [ch for row in chars for ch in row]
ords = np.frombuffer(np.array(flat, dtype='U1'), dtype=np.uint32)
char_indices = ord_lookup[ords].reshape(rows, cols)
# --- Vectorised mask assembly ---
# atlas[char_indices] -> (rows, cols, cell_size, cell_size)
# Transpose to (rows, cell_size, cols, cell_size) then reshape to full image.
all_masks = atlas[char_indices]
full_mask = all_masks.transpose(0, 2, 1, 3).reshape(h, w)
# Expand per-cell colours to per-pixel (only when needed).
need_color_full = (color_mode in ("color", "invert")
or (fg_color is None and color_mode != "mono"))
if need_color_full:
color_full = np.repeat(
np.repeat(colors[:rows, :cols], cell_size, axis=0),
cell_size, axis=1)
# --- Vectorised colour composite ---
# Use element-wise multiply/np.where instead of boolean-indexed scatter
# for much better memory access patterns.
mask_u8 = (full_mask > 0).astype(np.uint8)[:, :, np.newaxis]
if color_mode == "invert":
# Background is source colour; characters are black.
# result = color_full * (1 - mask)
result = color_full * (1 - mask_u8)
elif fg_color is not None:
# Fixed foreground colour on background.
fg = np.array(fg_color, dtype=np.uint8)
bg_arr = np.array(bg, dtype=np.uint8)
result = np.where(mask_u8, fg, bg_arr).astype(np.uint8)
elif color_mode == "mono":
bg_arr = np.array(bg, dtype=np.uint8)
result = np.where(mask_u8, np.uint8(255), bg_arr).astype(np.uint8)
else:
# "color" mode each cell uses its source colour on bg.
if bg == [0, 0, 0]:
result = color_full * mask_u8
else:
bg_arr = np.array(bg, dtype=np.uint8)
result = np.where(mask_u8, color_full, bg_arr).astype(np.uint8)
# Resize to match original if needed
orig_h, orig_w = img.shape[:2]
if result.shape[0] != orig_h or result.shape[1] != orig_w:
padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8)
copy_h = min(h, orig_h)
copy_w = min(w, orig_w)
padded[:copy_h, :copy_w] = result[:copy_h, :copy_w]
result = padded
return result
def prim_render_char_grid_fx(img: np.ndarray, chars: List[List[str]], colors: np.ndarray,
luminances: np.ndarray, cell_size: int,
color_mode: str = "color",
background_color: str = "black",
invert_colors: bool = False,
char_jitter: float = 0.0,
char_scale: float = 1.0,
char_rotation: float = 0.0,
char_hue_shift: float = 0.0,
jitter_source: str = "none",
scale_source: str = "none",
rotation_source: str = "none",
hue_source: str = "none") -> np.ndarray:
"""
Render a grid of characters with per-character effects.
Args:
img: source image (for dimensions)
chars: 2D list of single characters
colors: (rows, cols, 3) array of colors per cell
luminances: (rows, cols) array of luminance values (0-255)
cell_size: size of each cell
color_mode: "color", "mono", "invert", or any color name/hex
background_color: background color name/hex
invert_colors: if True, swap foreground and background colors
char_jitter: base jitter amount in pixels
char_scale: base scale factor (1.0 = normal)
char_rotation: base rotation in degrees
char_hue_shift: base hue shift in degrees (0-360)
jitter_source: source for jitter modulation ("none", "luminance", "position", "random")
scale_source: source for scale modulation
rotation_source: source for rotation modulation
hue_source: source for hue modulation
Per-character effect sources:
"none" - use base value only
"luminance" - modulate by cell luminance (0-1)
"inv_luminance" - modulate by inverse luminance (dark = high)
"saturation" - modulate by cell color saturation
"position_x" - modulate by horizontal position (0-1)
"position_y" - modulate by vertical position (0-1)
"position_diag" - modulate by diagonal position
"random" - random per-cell value (deterministic from position)
"center_dist" - distance from center (0=center, 1=corner)
Returns: rendered image
"""
# Parse colors
fg_color = parse_color(color_mode)
if isinstance(background_color, (list, tuple)):
bg_color = tuple(int(c) for c in background_color[:3])
else:
bg_color = parse_color(background_color)
if bg_color is None:
bg_color = (0, 0, 0)
if invert_colors and fg_color is not None:
fg_color, bg_color = bg_color, fg_color
cell_size = max(1, int(cell_size))
if not chars or not chars[0]:
return img.copy()
rows = len(chars)
cols = len(chars[0])
h, w = rows * cell_size, cols * cell_size
bg = list(bg_color)
result = np.full((h, w, 3), bg, dtype=np.uint8)
# Normalize luminances to 0-1
lum_normalized = luminances.astype(np.float32) / 255.0
# Compute saturation from colors
colors_float = colors.astype(np.float32) / 255.0
max_c = colors_float.max(axis=2)
min_c = colors_float.min(axis=2)
saturation = np.where(max_c > 0, (max_c - min_c) / max_c, 0)
# Helper to get modulation value for a cell
def get_mod_value(source: str, r: int, c: int) -> float:
if source == "none":
return 1.0
elif source == "luminance":
return lum_normalized[r, c]
elif source == "inv_luminance":
return 1.0 - lum_normalized[r, c]
elif source == "saturation":
return saturation[r, c]
elif source == "position_x":
return c / max(1, cols - 1) if cols > 1 else 0.5
elif source == "position_y":
return r / max(1, rows - 1) if rows > 1 else 0.5
elif source == "position_diag":
px = c / max(1, cols - 1) if cols > 1 else 0.5
py = r / max(1, rows - 1) if rows > 1 else 0.5
return (px + py) / 2.0
elif source == "random":
# Deterministic random based on position
seed = (r * 1000 + c) % 10000
return ((seed * 9301 + 49297) % 233280) / 233280.0
elif source == "center_dist":
cx, cy = (cols - 1) / 2.0, (rows - 1) / 2.0
dx = (c - cx) / max(1, cx) if cx > 0 else 0
dy = (r - cy) / max(1, cy) if cy > 0 else 0
return min(1.0, math.sqrt(dx*dx + dy*dy))
else:
return 1.0
# Build character atlas at base size
font = cv2.FONT_HERSHEY_SIMPLEX
base_font_scale = cell_size / 20.0
thickness = max(1, int(cell_size / 10))
unique_chars = set()
for row in chars:
for ch in row:
unique_chars.add(ch)
# For rotation/scale, we need to render characters larger then transform
max_scale = max(1.0, char_scale * 1.5) # Allow headroom for scaling
atlas_size = int(cell_size * max_scale * 1.5)
atlas = {}
for char in unique_chars:
if char and char != ' ':
try:
char_img = np.zeros((atlas_size, atlas_size), dtype=np.uint8)
scaled_font = base_font_scale * max_scale
(text_w, text_h), _ = cv2.getTextSize(char, font, scaled_font, thickness)
text_x = max(0, (atlas_size - text_w) // 2)
text_y = (atlas_size + text_h) // 2
cv2.putText(char_img, char, (text_x, text_y), font, scaled_font, 255, thickness, cv2.LINE_AA)
atlas[char] = char_img
except:
atlas[char] = None
else:
atlas[char] = None
# Render characters with effects
for r in range(rows):
for c in range(cols):
char = chars[r][c]
if not char or char == ' ':
continue
char_img = atlas.get(char)
if char_img is None:
continue
# Get per-cell modulation values
jitter_mod = get_mod_value(jitter_source, r, c)
scale_mod = get_mod_value(scale_source, r, c)
rot_mod = get_mod_value(rotation_source, r, c)
hue_mod = get_mod_value(hue_source, r, c)
# Compute effective values
eff_jitter = char_jitter * jitter_mod
eff_scale = char_scale * (0.5 + 0.5 * scale_mod) if scale_source != "none" else char_scale
eff_rotation = char_rotation * (rot_mod * 2 - 1) # -1 to 1 range
eff_hue_shift = char_hue_shift * hue_mod
# Apply transformations
transformed = char_img.copy()
# Rotation
if abs(eff_rotation) > 0.5:
center = (atlas_size // 2, atlas_size // 2)
rot_matrix = cv2.getRotationMatrix2D(center, eff_rotation, 1.0)
transformed = cv2.warpAffine(transformed, rot_matrix, (atlas_size, atlas_size))
# Scale - resize to target size
target_size = max(1, int(cell_size * eff_scale))
if target_size != atlas_size:
transformed = cv2.resize(transformed, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
# Compute position with jitter
base_y = r * cell_size
base_x = c * cell_size
if eff_jitter > 0:
# Deterministic jitter based on position
jx = ((r * 7 + c * 13) % 100) / 100.0 - 0.5
jy = ((r * 11 + c * 17) % 100) / 100.0 - 0.5
base_x += int(jx * eff_jitter * 2)
base_y += int(jy * eff_jitter * 2)
# Center the character in the cell
offset = (target_size - cell_size) // 2
y1 = base_y - offset
x1 = base_x - offset
# Determine color
if fg_color is not None:
color = np.array(fg_color, dtype=np.uint8)
elif color_mode == "mono":
color = np.array([255, 255, 255], dtype=np.uint8)
elif color_mode == "invert":
# Fill cell with source color first
cy1 = max(0, r * cell_size)
cy2 = min(h, (r + 1) * cell_size)
cx1 = max(0, c * cell_size)
cx2 = min(w, (c + 1) * cell_size)
result[cy1:cy2, cx1:cx2] = colors[r, c]
color = np.array([0, 0, 0], dtype=np.uint8)
else: # color mode
color = colors[r, c].copy()
# Apply hue shift
if abs(eff_hue_shift) > 0.5 and color_mode not in ("mono", "invert") and fg_color is None:
# Convert to HSV, shift hue, convert back
color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV)
# Cast to int to avoid uint8 overflow, then back to uint8
new_hue = (int(color_hsv[0, 0, 0]) + int(eff_hue_shift * 180 / 360)) % 180
color_hsv[0, 0, 0] = np.uint8(new_hue)
color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten()
# Blit character to result
mask = transformed > 0
th, tw = transformed.shape[:2]
for dy in range(th):
for dx in range(tw):
py = y1 + dy
px = x1 + dx
if 0 <= py < h and 0 <= px < w and mask[dy, dx]:
result[py, px] = color
# Resize to match original if needed
orig_h, orig_w = img.shape[:2]
if result.shape[0] != orig_h or result.shape[1] != orig_w:
padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8)
copy_h = min(h, orig_h)
copy_w = min(w, orig_w)
padded[:copy_h, :copy_w] = result[:copy_h, :copy_w]
result = padded
return result
def _render_with_cell_effect(
frame: np.ndarray,
chars: List[List[str]],
colors: np.ndarray,
luminances: np.ndarray,
zone_contexts: List[List['ZoneContext']],
cell_size: int,
bg_color: tuple,
fg_color: tuple,
color_mode: str,
cell_effect, # Lambda or callable: (cell_image, zone_dict) -> cell_image
extra_params: dict,
interp,
env,
result: np.ndarray,
) -> np.ndarray:
"""
Render ASCII art using a cell_effect lambda for arbitrary per-cell transforms.
Each character is rendered to a cell image, the cell_effect is called with
(cell_image, zone_dict), and the returned cell is composited into result.
This allows arbitrary effects (rotate, blur, etc.) to be applied per-character.
"""
grid_rows = len(chars)
grid_cols = len(chars[0]) if chars else 0
out_h, out_w = result.shape[:2]
# Build character atlas (cell-sized colored characters on transparent bg)
font = cv2.FONT_HERSHEY_SIMPLEX
font_scale = cell_size / 20.0
thickness = max(1, int(cell_size / 10))
# Helper to render a single character cell
def render_char_cell(char: str, color: np.ndarray) -> np.ndarray:
"""Render a character onto a cell-sized RGB image."""
cell = np.full((cell_size, cell_size, 3), bg_color, dtype=np.uint8)
if not char or char == ' ':
return cell
try:
(text_w, text_h), _ = cv2.getTextSize(char, font, font_scale, thickness)
text_x = max(0, (cell_size - text_w) // 2)
text_y = (cell_size + text_h) // 2
# Render character in white on mask, then apply color
mask = np.zeros((cell_size, cell_size), dtype=np.uint8)
cv2.putText(mask, char, (text_x, text_y), font, font_scale, 255, thickness, cv2.LINE_AA)
# Apply color where mask is set
for ch in range(3):
cell[:, :, ch] = np.where(mask > 0, color[ch], bg_color[ch])
except:
pass
return cell
# Helper to evaluate cell_effect (handles artdag Lambda objects)
def eval_cell_effect(cell_img: np.ndarray, zone_dict: dict) -> np.ndarray:
"""Call cell_effect with (cell_image, zone_dict), handle Lambda objects."""
if callable(cell_effect):
return cell_effect(cell_img, zone_dict)
# Check if it's an artdag Lambda object
try:
from artdag.sexp.parser import Lambda as ArtdagLambda
from artdag.sexp.evaluator import evaluate as artdag_evaluate
if isinstance(cell_effect, ArtdagLambda):
# Build env with closure values
eval_env = dict(cell_effect.closure) if cell_effect.closure else {}
# Bind lambda parameters
if len(cell_effect.params) >= 2:
eval_env[cell_effect.params[0]] = cell_img
eval_env[cell_effect.params[1]] = zone_dict
elif len(cell_effect.params) == 1:
# Single param gets zone_dict with cell as 'cell' key
zone_dict['cell'] = cell_img
eval_env[cell_effect.params[0]] = zone_dict
# Add primitives to eval env
eval_env.update(PRIMITIVES)
# Add effect runner - allows calling any loaded sexp effect on a cell
# Usage: (apply-effect "effect_name" cell {"param" value ...})
# Or: (apply-effect "effect_name" cell) for defaults
def apply_effect_fn(effect_name, frame, params=None):
"""Run a loaded sexp effect on a frame (cell)."""
if interp and hasattr(interp, 'run_effect'):
if params is None:
params = {}
result, _ = interp.run_effect(effect_name, frame, params, {})
return result
return frame
eval_env['apply-effect'] = apply_effect_fn
# Also inject loaded effects directly as callable functions
# These wrappers take positional args in common order for each effect
# Usage: (blur cell 5) or (rotate cell 45) etc.
if interp and hasattr(interp, 'effects'):
for effect_name in interp.effects:
# Create a wrapper that calls run_effect with positional-to-named mapping
def make_effect_fn(name):
def effect_fn(frame, *args):
# Map common positional args to named params
params = {}
if name == 'blur' and len(args) >= 1:
params['radius'] = args[0]
elif name == 'rotate' and len(args) >= 1:
params['angle'] = args[0]
elif name == 'brightness' and len(args) >= 1:
params['factor'] = args[0]
elif name == 'contrast' and len(args) >= 1:
params['factor'] = args[0]
elif name == 'saturation' and len(args) >= 1:
params['factor'] = args[0]
elif name == 'hue_shift' and len(args) >= 1:
params['degrees'] = args[0]
elif name == 'rgb_split' and len(args) >= 1:
params['offset_x'] = args[0]
if len(args) >= 2:
params['offset_y'] = args[1]
elif name == 'pixelate' and len(args) >= 1:
params['block_size'] = args[0]
elif name == 'wave' and len(args) >= 1:
params['amplitude'] = args[0]
if len(args) >= 2:
params['frequency'] = args[1]
elif name == 'noise' and len(args) >= 1:
params['amount'] = args[0]
elif name == 'posterize' and len(args) >= 1:
params['levels'] = args[0]
elif name == 'threshold' and len(args) >= 1:
params['level'] = args[0]
elif name == 'sharpen' and len(args) >= 1:
params['amount'] = args[0]
elif len(args) == 1 and isinstance(args[0], dict):
# Accept dict as single arg
params = args[0]
result, _ = interp.run_effect(name, frame, params, {})
return result
return effect_fn
eval_env[effect_name] = make_effect_fn(effect_name)
result = artdag_evaluate(cell_effect.body, eval_env)
if isinstance(result, np.ndarray):
return result
return cell_img
except ImportError:
pass
# Fallback: return cell unchanged
return cell_img
# Render each cell
for r in range(grid_rows):
for c in range(grid_cols):
char = chars[r][c]
zone = zone_contexts[r][c]
# Determine character color
if fg_color is not None:
color = np.array(fg_color, dtype=np.uint8)
elif color_mode == "mono":
color = np.array([255, 255, 255], dtype=np.uint8)
elif color_mode == "invert":
color = np.array([0, 0, 0], dtype=np.uint8)
else:
color = colors[r, c].copy()
# Render character to cell image
cell_img = render_char_cell(char, color)
# Build zone dict
zone_dict = {
'row': zone.row,
'col': zone.col,
'row-norm': zone.row_norm,
'col-norm': zone.col_norm,
'lum': zone.luminance,
'sat': zone.saturation,
'hue': zone.hue,
'r': zone.r,
'g': zone.g,
'b': zone.b,
'char': char,
'color': color.tolist(),
'cell_size': cell_size,
}
# Add extra params (energy, rotation_scale, etc.)
if extra_params:
zone_dict.update(extra_params)
# Call cell_effect
modified_cell = eval_cell_effect(cell_img, zone_dict)
# Ensure result is valid
if modified_cell is None or not isinstance(modified_cell, np.ndarray):
modified_cell = cell_img
if modified_cell.shape[:2] != (cell_size, cell_size):
# Resize if cell size changed
modified_cell = cv2.resize(modified_cell, (cell_size, cell_size))
if len(modified_cell.shape) == 2:
# Convert grayscale to RGB
modified_cell = cv2.cvtColor(modified_cell, cv2.COLOR_GRAY2RGB)
# Composite into result
y1 = r * cell_size
x1 = c * cell_size
y2 = min(y1 + cell_size, out_h)
x2 = min(x1 + cell_size, out_w)
ch = y2 - y1
cw = x2 - x1
result[y1:y2, x1:x2] = modified_cell[:ch, :cw]
# Resize to match original frame if needed
orig_h, orig_w = frame.shape[:2]
if result.shape[0] != orig_h or result.shape[1] != orig_w:
bg = list(bg_color)
padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8)
copy_h = min(out_h, orig_h)
copy_w = min(out_w, orig_w)
padded[:copy_h, :copy_w] = result[:copy_h, :copy_w]
result = padded
return result
def prim_ascii_fx_zone(
frame: np.ndarray,
cols: int,
char_size_override: int, # If set, overrides cols-based calculation
alphabet: str,
color_mode: str,
background: str,
contrast: float,
char_hue_expr, # Expression, literal, or None
char_sat_expr, # Expression, literal, or None
char_bright_expr, # Expression, literal, or None
char_scale_expr, # Expression, literal, or None
char_rotation_expr, # Expression, literal, or None
char_jitter_expr, # Expression, literal, or None
interp, # Interpreter for expression evaluation
env, # Environment with bound values
extra_params=None, # Extra params to include in zone dict for lambdas
cell_effect=None, # Lambda (cell_image, zone_dict) -> cell_image for arbitrary cell effects
) -> np.ndarray:
"""
Render ASCII art with per-zone expression-driven transforms.
Args:
frame: Source image (H, W, 3) RGB uint8
cols: Number of character columns
char_size_override: If set, use this cell size instead of cols-based
alphabet: Character set name or literal string
color_mode: "color", "mono", "invert", or color name/hex
background: Background color name or hex
contrast: Contrast boost for character selection
char_hue_expr: Expression for hue shift (evaluated per zone)
char_sat_expr: Expression for saturation adjustment (evaluated per zone)
char_bright_expr: Expression for brightness adjustment (evaluated per zone)
char_scale_expr: Expression for scale factor (evaluated per zone)
char_rotation_expr: Expression for rotation degrees (evaluated per zone)
char_jitter_expr: Expression for position jitter (evaluated per zone)
interp: Interpreter instance for expression evaluation
env: Environment with bound variables
cell_effect: Optional lambda that receives (cell_image, zone_dict) and returns
a modified cell_image. When provided, each character is rendered
to a cell image, passed to this lambda, and the result composited.
This allows arbitrary effects to be applied per-character.
Zone variables available in expressions:
zone-row, zone-col: Grid position (integers)
zone-row-norm, zone-col-norm: Normalized position (0-1)
zone-lum: Cell luminance (0-1)
zone-sat: Cell saturation (0-1)
zone-hue: Cell hue (0-360)
zone-r, zone-g, zone-b: RGB components (0-1)
Returns: Rendered image
"""
h, w = frame.shape[:2]
# Use char_size if provided, otherwise calculate from cols
if char_size_override is not None:
cell_size = max(4, int(char_size_override))
else:
cell_size = max(4, w // cols)
# Get zone data using extended sampling
colors, luminances, zone_contexts = cell_sample_extended(frame, cell_size)
# Convert luminances to characters
chars = prim_luminance_to_chars(luminances, alphabet, contrast)
grid_rows = len(chars)
grid_cols = len(chars[0]) if chars else 0
# Parse colors
fg_color = parse_color(color_mode)
if isinstance(background, (list, tuple)):
bg_color = tuple(int(c) for c in background[:3])
else:
bg_color = parse_color(background)
if bg_color is None:
bg_color = (0, 0, 0)
# Arrays for per-zone transform values
hue_shifts = np.zeros((grid_rows, grid_cols), dtype=np.float32)
saturations = np.ones((grid_rows, grid_cols), dtype=np.float32)
brightness = np.ones((grid_rows, grid_cols), dtype=np.float32)
scales = np.ones((grid_rows, grid_cols), dtype=np.float32)
rotations = np.zeros((grid_rows, grid_cols), dtype=np.float32)
jitters = np.zeros((grid_rows, grid_cols), dtype=np.float32)
# Helper to evaluate expression or return literal value
def eval_expr(expr, zone, char):
if expr is None:
return None
if isinstance(expr, (int, float)):
return expr
# Build zone dict for lambda calls
zone_dict = {
'row': zone.row,
'col': zone.col,
'row-norm': zone.row_norm,
'col-norm': zone.col_norm,
'lum': zone.luminance,
'sat': zone.saturation,
'hue': zone.hue,
'r': zone.r,
'g': zone.g,
'b': zone.b,
'char': char,
}
# Add extra params (energy, rotation_scale, etc.) for lambdas to access
if extra_params:
zone_dict.update(extra_params)
# Check if it's a Python callable
if callable(expr):
return expr(zone_dict)
# Check if it's an artdag Lambda object
try:
from artdag.sexp.parser import Lambda as ArtdagLambda
from artdag.sexp.evaluator import evaluate as artdag_evaluate
if isinstance(expr, ArtdagLambda):
# Build env with zone dict and any closure values
eval_env = dict(expr.closure) if expr.closure else {}
# Bind the lambda parameter to zone_dict
if expr.params:
eval_env[expr.params[0]] = zone_dict
return artdag_evaluate(expr.body, eval_env)
except ImportError:
pass
# It's an expression - evaluate with zone context (sexp_effects style)
return interp.eval_with_zone(expr, env, zone)
# Evaluate expressions for each zone
for r in range(grid_rows):
for c in range(grid_cols):
zone = zone_contexts[r][c]
char = chars[r][c]
val = eval_expr(char_hue_expr, zone, char)
if val is not None:
hue_shifts[r, c] = float(val)
val = eval_expr(char_sat_expr, zone, char)
if val is not None:
saturations[r, c] = float(val)
val = eval_expr(char_bright_expr, zone, char)
if val is not None:
brightness[r, c] = float(val)
val = eval_expr(char_scale_expr, zone, char)
if val is not None:
scales[r, c] = float(val)
val = eval_expr(char_rotation_expr, zone, char)
if val is not None:
rotations[r, c] = float(val)
val = eval_expr(char_jitter_expr, zone, char)
if val is not None:
jitters[r, c] = float(val)
# Now render with computed transform arrays
out_h, out_w = grid_rows * cell_size, grid_cols * cell_size
bg = list(bg_color)
result = np.full((out_h, out_w, 3), bg, dtype=np.uint8)
# If cell_effect is provided, use the cell-mapper rendering path
if cell_effect is not None:
return _render_with_cell_effect(
frame, chars, colors, luminances, zone_contexts,
cell_size, bg_color, fg_color, color_mode,
cell_effect, extra_params, interp, env, result
)
# Build character atlas
font = cv2.FONT_HERSHEY_SIMPLEX
base_font_scale = cell_size / 20.0
thickness = max(1, int(cell_size / 10))
unique_chars = set()
for row in chars:
for ch in row:
unique_chars.add(ch)
# For rotation/scale, render characters larger then transform
max_scale = max(1.0, np.max(scales) * 1.5)
atlas_size = int(cell_size * max_scale * 1.5)
atlas = {}
for char in unique_chars:
if char and char != ' ':
try:
char_img = np.zeros((atlas_size, atlas_size), dtype=np.uint8)
scaled_font = base_font_scale * max_scale
(text_w, text_h), _ = cv2.getTextSize(char, font, scaled_font, thickness)
text_x = max(0, (atlas_size - text_w) // 2)
text_y = (atlas_size + text_h) // 2
cv2.putText(char_img, char, (text_x, text_y), font, scaled_font, 255, thickness, cv2.LINE_AA)
atlas[char] = char_img
except:
atlas[char] = None
else:
atlas[char] = None
# Render characters with per-zone effects
for r in range(grid_rows):
for c in range(grid_cols):
char = chars[r][c]
if not char or char == ' ':
continue
char_img = atlas.get(char)
if char_img is None:
continue
# Get per-cell values
eff_scale = scales[r, c]
eff_rotation = rotations[r, c]
eff_jitter = jitters[r, c]
eff_hue_shift = hue_shifts[r, c]
eff_brightness = brightness[r, c]
eff_saturation = saturations[r, c]
# Apply transformations to character
transformed = char_img.copy()
# Rotation
if abs(eff_rotation) > 0.5:
center = (atlas_size // 2, atlas_size // 2)
rot_matrix = cv2.getRotationMatrix2D(center, eff_rotation, 1.0)
transformed = cv2.warpAffine(transformed, rot_matrix, (atlas_size, atlas_size))
# Scale - resize to target size
target_size = max(1, int(cell_size * eff_scale))
if target_size != atlas_size:
transformed = cv2.resize(transformed, (target_size, target_size), interpolation=cv2.INTER_LINEAR)
# Compute position with jitter
base_y = r * cell_size
base_x = c * cell_size
if eff_jitter > 0:
# Deterministic jitter based on position
jx = ((r * 7 + c * 13) % 100) / 100.0 - 0.5
jy = ((r * 11 + c * 17) % 100) / 100.0 - 0.5
base_x += int(jx * eff_jitter * 2)
base_y += int(jy * eff_jitter * 2)
# Center the character in the cell
offset = (target_size - cell_size) // 2
y1 = base_y - offset
x1 = base_x - offset
# Determine color
if fg_color is not None:
color = np.array(fg_color, dtype=np.uint8)
elif color_mode == "mono":
color = np.array([255, 255, 255], dtype=np.uint8)
elif color_mode == "invert":
cy1 = max(0, r * cell_size)
cy2 = min(out_h, (r + 1) * cell_size)
cx1 = max(0, c * cell_size)
cx2 = min(out_w, (c + 1) * cell_size)
result[cy1:cy2, cx1:cx2] = colors[r, c]
color = np.array([0, 0, 0], dtype=np.uint8)
else: # color mode - use source colors
color = colors[r, c].copy()
# Apply hue shift
if abs(eff_hue_shift) > 0.5 and color_mode not in ("mono", "invert") and fg_color is None:
color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV)
new_hue = (int(color_hsv[0, 0, 0]) + int(eff_hue_shift * 180 / 360)) % 180
color_hsv[0, 0, 0] = np.uint8(new_hue)
color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten()
# Apply saturation adjustment
if abs(eff_saturation - 1.0) > 0.01 and color_mode not in ("mono", "invert") and fg_color is None:
color_hsv = cv2.cvtColor(color.reshape(1, 1, 3), cv2.COLOR_RGB2HSV)
new_sat = np.clip(int(color_hsv[0, 0, 1] * eff_saturation), 0, 255)
color_hsv[0, 0, 1] = np.uint8(new_sat)
color = cv2.cvtColor(color_hsv, cv2.COLOR_HSV2RGB).flatten()
# Apply brightness adjustment
if abs(eff_brightness - 1.0) > 0.01:
color = np.clip(color.astype(np.float32) * eff_brightness, 0, 255).astype(np.uint8)
# Blit character to result
mask = transformed > 0
th, tw = transformed.shape[:2]
for dy in range(th):
for dx in range(tw):
py = y1 + dy
px = x1 + dx
if 0 <= py < out_h and 0 <= px < out_w and mask[dy, dx]:
result[py, px] = color
# Resize to match original if needed
orig_h, orig_w = frame.shape[:2]
if result.shape[0] != orig_h or result.shape[1] != orig_w:
padded = np.full((orig_h, orig_w, 3), bg, dtype=np.uint8)
copy_h = min(out_h, orig_h)
copy_w = min(out_w, orig_w)
padded[:copy_h, :copy_w] = result[:copy_h, :copy_w]
result = padded
return result
def prim_make_char_grid(rows: int, cols: int, fill_char: str = " ") -> List[List[str]]:
"""Create a character grid filled with a character."""
return [[fill_char for _ in range(cols)] for _ in range(rows)]
def prim_set_char(chars: List[List[str]], row: int, col: int, char: str) -> List[List[str]]:
"""Set a character at position (returns modified copy)."""
result = [r[:] for r in chars] # shallow copy rows
if 0 <= row < len(result) and 0 <= col < len(result[0]):
result[row][col] = char
return result
def prim_get_char(chars: List[List[str]], row: int, col: int) -> str:
"""Get character at position."""
if 0 <= row < len(chars) and 0 <= col < len(chars[0]):
return chars[row][col]
return " "
def prim_char_grid_dimensions(chars: List[List[str]]) -> Tuple[int, int]:
"""Get (rows, cols) of character grid."""
if not chars:
return (0, 0)
return (len(chars), len(chars[0]) if chars[0] else 0)
def prim_alphabet_char(alphabet: str, index: int) -> str:
"""Get character at index from alphabet (wraps around)."""
chars = CHAR_ALPHABETS.get(alphabet, alphabet)
if not chars:
return " "
return chars[int(index) % len(chars)]
def prim_alphabet_length(alphabet: str) -> int:
"""Get length of alphabet."""
chars = CHAR_ALPHABETS.get(alphabet, alphabet)
return len(chars)
def prim_map_char_grid(chars: List[List[str]], luminances: np.ndarray, fn: Callable) -> List[List[str]]:
"""
Map a function over character grid.
fn receives (row, col, char, luminance) and returns new character.
This allows per-cell character selection based on position, brightness, etc.
Example:
(map-char-grid chars luminances
(lambda (r c ch lum)
(if (> lum 128)
(alphabet-char "blocks" (floor (/ lum 50)))
ch)))
"""
if not chars or not chars[0]:
return chars
rows = len(chars)
cols = len(chars[0])
result = []
for r in range(rows):
row = []
for c in range(cols):
ch = chars[r][c]
lum = float(luminances[r, c]) if r < luminances.shape[0] and c < luminances.shape[1] else 0
new_ch = fn(r, c, ch, lum)
row.append(str(new_ch) if new_ch else " ")
result.append(row)
return result
def prim_map_colors(colors: np.ndarray, fn: Callable) -> np.ndarray:
"""
Map a function over color grid.
fn receives (row, col, color) and returns new [r, g, b].
Color is a list [r, g, b].
"""
if colors.size == 0:
return colors
rows, cols = colors.shape[:2]
result = colors.copy()
for r in range(rows):
for c in range(cols):
color = list(colors[r, c])
new_color = fn(r, c, color)
if new_color is not None:
result[r, c] = new_color[:3]
return result
# =============================================================================
# Glitch Art Primitives
# =============================================================================
def prim_pixelsort(img: np.ndarray, sort_by: str = "lightness",
threshold_low: float = 50, threshold_high: float = 200,
angle: float = 0, reverse: bool = False) -> np.ndarray:
"""
Pixel sorting glitch effect.
Args:
img: source image
sort_by: "lightness", "hue", "saturation", "red", "green", "blue"
threshold_low: pixels below this aren't sorted
threshold_high: pixels above this aren't sorted
angle: 0 = horizontal, 90 = vertical
reverse: reverse sort order
"""
h, w = img.shape[:2]
# Rotate for vertical sorting
if 45 <= (angle % 180) <= 135:
frame = np.transpose(img, (1, 0, 2))
h, w = frame.shape[:2]
rotated = True
else:
frame = img
rotated = False
result = frame.copy()
# Get sort values
if sort_by == "lightness":
sort_values = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY).astype(np.float32)
elif sort_by == "hue":
hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV)
sort_values = hsv[:, :, 0].astype(np.float32)
elif sort_by == "saturation":
hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV)
sort_values = hsv[:, :, 1].astype(np.float32)
elif sort_by == "red":
sort_values = frame[:, :, 0].astype(np.float32)
elif sort_by == "green":
sort_values = frame[:, :, 1].astype(np.float32)
elif sort_by == "blue":
sort_values = frame[:, :, 2].astype(np.float32)
else:
sort_values = cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY).astype(np.float32)
# Create mask
mask = (sort_values >= threshold_low) & (sort_values <= threshold_high)
# Sort each row
for y in range(h):
row = result[y].copy()
row_mask = mask[y]
row_values = sort_values[y]
# Find contiguous segments
segments = []
start = None
for i, val in enumerate(row_mask):
if val and start is None:
start = i
elif not val and start is not None:
segments.append((start, i))
start = None
if start is not None:
segments.append((start, len(row_mask)))
# Sort each segment
for seg_start, seg_end in segments:
if seg_end - seg_start > 1:
segment_values = row_values[seg_start:seg_end]
sort_indices = np.argsort(segment_values)
if reverse:
sort_indices = sort_indices[::-1]
row[seg_start:seg_end] = row[seg_start:seg_end][sort_indices]
result[y] = row
# Rotate back
if rotated:
result = np.transpose(result, (1, 0, 2))
return np.ascontiguousarray(result)
def prim_datamosh(img: np.ndarray, prev_frame: np.ndarray,
block_size: int = 32, corruption: float = 0.3,
max_offset: int = 50, color_corrupt: bool = True) -> np.ndarray:
"""
Datamosh/glitch block corruption effect.
Args:
img: current frame
prev_frame: previous frame (or None)
block_size: size of corruption blocks
corruption: probability 0-1 of corrupting each block
max_offset: maximum pixel shift
color_corrupt: also apply color channel shifts
"""
if corruption <= 0:
return img.copy()
block_size = max(8, min(int(block_size), 128))
h, w = img.shape[:2]
result = img.copy()
for by in range(0, h, block_size):
for bx in range(0, w, block_size):
bh = min(block_size, h - by)
bw = min(block_size, w - bx)
if _rng.random() < corruption:
corruption_type = _rng.randint(0, 3)
if corruption_type == 0 and max_offset > 0:
# Shift
ox = _rng.randint(-max_offset, max_offset)
oy = _rng.randint(-max_offset, max_offset)
src_x = max(0, min(bx + ox, w - bw))
src_y = max(0, min(by + oy, h - bh))
result[by:by+bh, bx:bx+bw] = img[src_y:src_y+bh, src_x:src_x+bw]
elif corruption_type == 1 and prev_frame is not None:
# Duplicate from previous frame
if prev_frame.shape == img.shape:
result[by:by+bh, bx:bx+bw] = prev_frame[by:by+bh, bx:bx+bw]
elif corruption_type == 2 and color_corrupt:
# Color channel shift
block = result[by:by+bh, bx:bx+bw].copy()
shift = _rng.randint(1, 3)
channel = _rng.randint(0, 2)
block[:, :, channel] = np.roll(block[:, :, channel], shift, axis=0)
result[by:by+bh, bx:bx+bw] = block
else:
# Swap with another block
other_bx = _rng.randint(0, max(0, w - bw))
other_by = _rng.randint(0, max(0, h - bh))
temp = result[by:by+bh, bx:bx+bw].copy()
result[by:by+bh, bx:bx+bw] = img[other_by:other_by+bh, other_bx:other_bx+bw]
result[other_by:other_by+bh, other_bx:other_bx+bw] = temp
return result
def prim_ripple_displace(w: int, h: int, freq: float, amp: float, cx: float = None, cy: float = None,
decay: float = 0, phase: float = 0) -> Tuple[np.ndarray, np.ndarray]:
"""
Create radial ripple displacement maps.
Args:
w, h: dimensions
freq: ripple frequency
amp: ripple amplitude in pixels
cx, cy: center
decay: how fast ripples decay with distance (0 = no decay)
phase: phase offset
Returns: (map_x, map_y) for use with remap
"""
w, h = int(w), int(h)
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
dx = x_coords - cx
dy = y_coords - cy
dist = np.sqrt(dx**2 + dy**2)
# Calculate ripple displacement (radial)
ripple = np.sin(2 * np.pi * freq * dist / max(w, h) + phase) * amp
# Apply decay
if decay > 0:
ripple = ripple * np.exp(-dist * decay / max(w, h))
# Displace along radial direction
with np.errstate(divide='ignore', invalid='ignore'):
norm_dx = np.where(dist > 0, dx / dist, 0)
norm_dy = np.where(dist > 0, dy / dist, 0)
map_x = (x_coords + ripple * norm_dx).astype(np.float32)
map_y = (y_coords + ripple * norm_dy).astype(np.float32)
return (map_x, map_y)
PRIMITIVES = {
# Arithmetic
'+': prim_add,
'-': prim_sub,
'*': prim_mul,
'/': prim_div,
# Comparison
'<': prim_lt,
'>': prim_gt,
'<=': prim_le,
'>=': prim_ge,
'=': prim_eq,
'!=': prim_ne,
# Image
'width': prim_width,
'height': prim_height,
'make-image': prim_make_image,
'copy': prim_copy,
'pixel': prim_pixel,
'set-pixel': prim_set_pixel,
'sample': prim_sample,
'channel': prim_channel,
'merge-channels': prim_merge_channels,
'resize': prim_resize,
'crop': prim_crop,
'paste': prim_paste,
# Color
'rgb': prim_rgb,
'red': prim_red,
'green': prim_green,
'blue': prim_blue,
'luminance': prim_luminance,
'rgb->hsv': prim_rgb_to_hsv,
'hsv->rgb': prim_hsv_to_rgb,
'blend-color': prim_blend_color,
'average-color': prim_average_color,
# Vectorized bulk operations
'color-matrix': prim_color_matrix,
'adjust': prim_adjust,
'mix-gray': prim_mix_gray,
'invert-img': prim_invert_img,
'add-noise': prim_add_noise,
'quantize': prim_quantize,
'shift-hsv': prim_shift_hsv,
# Bulk operations
'map-pixels': prim_map_pixels,
'map-rows': prim_map_rows,
'for-grid': prim_for_grid,
'fold-pixels': prim_fold_pixels,
# Filters
'convolve': prim_convolve,
'blur': prim_blur,
'box-blur': prim_box_blur,
'edges': prim_edges,
'sobel': prim_sobel,
'dilate': prim_dilate,
'erode': prim_erode,
# Geometry
'translate': prim_translate,
'rotate-img': prim_rotate,
'scale-img': prim_scale,
'flip-h': prim_flip_h,
'flip-v': prim_flip_v,
'remap': prim_remap,
'make-coords': prim_make_coords,
# Blending
'blend-images': prim_blend_images,
'blend-mode': prim_blend_mode,
'mask': prim_mask,
# Drawing
'draw-char': prim_draw_char,
'draw-text': prim_draw_text,
'fill-rect': prim_fill_rect,
'draw-line': prim_draw_line,
# Math
'sin': prim_sin,
'cos': prim_cos,
'tan': prim_tan,
'atan2': prim_atan2,
'sqrt': prim_sqrt,
'pow': prim_pow,
'abs': prim_abs,
'floor': prim_floor,
'ceil': prim_ceil,
'round': prim_round,
'min': prim_min,
'max': prim_max,
'clamp': prim_clamp,
'lerp': prim_lerp,
'mod': prim_mod,
'random': prim_random,
'randint': prim_randint,
'gaussian': prim_gaussian,
'assert': prim_assert,
'pi': math.pi,
'tau': math.tau,
# Array
'length': prim_length,
'len': prim_length, # alias
'nth': prim_nth,
'first': prim_first,
'rest': prim_rest,
'take': prim_take,
'drop': prim_drop,
'cons': prim_cons,
'append': prim_append,
'reverse': prim_reverse,
'range': prim_range,
'roll': prim_roll,
'list': prim_list,
# Array math (vectorized operations on coordinate arrays)
'arr+': prim_arr_add,
'arr-': prim_arr_sub,
'arr*': prim_arr_mul,
'arr/': prim_arr_div,
'arr-mod': prim_arr_mod,
'arr-sin': prim_arr_sin,
'arr-cos': prim_arr_cos,
'arr-tan': prim_arr_tan,
'arr-sqrt': prim_arr_sqrt,
'arr-pow': prim_arr_pow,
'arr-abs': prim_arr_abs,
'arr-neg': prim_arr_neg,
'arr-exp': prim_arr_exp,
'arr-atan2': prim_arr_atan2,
'arr-min': prim_arr_min,
'arr-max': prim_arr_max,
'arr-clip': prim_arr_clip,
'arr-where': prim_arr_where,
'arr-floor': prim_arr_floor,
'arr-lerp': prim_arr_lerp,
# Coordinate transformations
'polar-from-center': prim_polar_from_center,
'cart-from-polar': prim_cart_from_polar,
'normalize-coords': prim_normalize_coords,
'coords-x': prim_coords_x,
'coords-y': prim_coords_y,
'make-coords-centered': prim_make_coords_centered,
# Specialized distortion maps
'wave-displace': prim_wave_displace,
'swirl-displace': prim_swirl_displace,
'fisheye-displace': prim_fisheye_displace,
'kaleidoscope-displace': prim_kaleidoscope_displace,
'ripple-displace': prim_ripple_displace,
# Character/ASCII art
'cell-sample': prim_cell_sample,
'cell-sample-extended': cell_sample_extended,
'luminance-to-chars': prim_luminance_to_chars,
'render-char-grid': prim_render_char_grid,
'render-char-grid-fx': prim_render_char_grid_fx,
'ascii-fx-zone': prim_ascii_fx_zone,
'make-char-grid': prim_make_char_grid,
'set-char': prim_set_char,
'get-char': prim_get_char,
'char-grid-dimensions': prim_char_grid_dimensions,
'alphabet-char': prim_alphabet_char,
'alphabet-length': prim_alphabet_length,
'map-char-grid': prim_map_char_grid,
'map-colors': prim_map_colors,
# Glitch art
'pixelsort': prim_pixelsort,
'datamosh': prim_datamosh,
}