- New streaming/ module for real-time video processing: - compositor.py: Main streaming compositor with cycle-crossfade - sexp_executor.py: Executes compiled sexp recipes in real-time - sexp_interp.py: Full S-expression interpreter for SLICE_ON Lambda - recipe_adapter.py: Bridges recipes to streaming compositor - sources.py: Video source with ffmpeg streaming - audio.py: Real-time audio analysis (energy, beats) - output.py: Preview (mpv) and file output with audio muxing - New templates/: - cycle-crossfade.sexp: Smooth zoom-based video cycling - process-pair.sexp: Dual-clip processing with effects - Key features: - Videos cycle in input-videos order (not definition order) - Cumulative whole-spin rotation - Zero-weight sources skip processing - Live audio-reactive effects - New effects: blend_multi for weighted layer compositing - Updated primitives and interpreter for streaming compatibility Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
3044 lines
100 KiB
Python
3044 lines
100 KiB
Python
"""
|
||
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,
|
||
|
||
}
|