Files
mono/artdag/sexp/primitives.py
giles cc2dcbddd4 Squashed 'core/' content from commit 4957443
git-subtree-dir: core
git-subtree-split: 4957443184ae0eb6323635a90a19acffb3e01d07
2026-02-24 23:09:39 +00:00

621 lines
17 KiB
Python

"""
Frame processing primitives for sexp effects.
These primitives are called by sexp effect definitions and operate on
numpy arrays (frames). They're used when falling back to Python rendering
instead of FFmpeg.
Required: numpy, PIL
"""
import math
from typing import Any, Dict, List, Optional, Tuple
try:
import numpy as np
HAS_NUMPY = True
except ImportError:
HAS_NUMPY = False
np = None
try:
from PIL import Image, ImageDraw, ImageFont
HAS_PIL = True
except ImportError:
HAS_PIL = False
# ASCII character sets for different styles
ASCII_ALPHABETS = {
"standard": " .:-=+*#%@",
"blocks": " ░▒▓█",
"simple": " .-:+=xX#",
"detailed": " .'`^\",:;Il!i><~+_-?][}{1)(|\\/tfjrxnuvczXYUJCLQ0OZmwqpdbkhao*#MW&8%B@$",
"binary": "",
}
def check_deps():
"""Check that required dependencies are available."""
if not HAS_NUMPY:
raise ImportError("numpy required for frame primitives: pip install numpy")
if not HAS_PIL:
raise ImportError("PIL required for frame primitives: pip install Pillow")
def frame_to_image(frame: np.ndarray) -> Image.Image:
"""Convert numpy frame to PIL Image."""
check_deps()
if frame.dtype != np.uint8:
frame = np.clip(frame, 0, 255).astype(np.uint8)
return Image.fromarray(frame)
def image_to_frame(img: Image.Image) -> np.ndarray:
"""Convert PIL Image to numpy frame."""
check_deps()
return np.array(img)
# ============================================================================
# ASCII Art Primitives
# ============================================================================
def cell_sample(frame: np.ndarray, cell_size: int = 8) -> Tuple[np.ndarray, np.ndarray]:
"""
Sample frame into cells, returning average colors and luminances.
Args:
frame: Input frame (H, W, C)
cell_size: Size of each cell
Returns:
(colors, luminances) - colors is (rows, cols, 3), luminances is (rows, cols)
"""
check_deps()
h, w = frame.shape[:2]
rows = h // cell_size
cols = w // cell_size
colors = np.zeros((rows, cols, 3), dtype=np.float32)
luminances = np.zeros((rows, cols), dtype=np.float32)
for r in range(rows):
for c in range(cols):
y0, y1 = r * cell_size, (r + 1) * cell_size
x0, x1 = c * cell_size, (c + 1) * cell_size
cell = frame[y0:y1, x0:x1]
# Average color
avg_color = cell.mean(axis=(0, 1))
colors[r, c] = avg_color[:3] # RGB only
# Luminance (ITU-R BT.601)
lum = 0.299 * avg_color[0] + 0.587 * avg_color[1] + 0.114 * avg_color[2]
luminances[r, c] = lum
return colors, luminances
def luminance_to_chars(
luminances: np.ndarray,
alphabet: str = "standard",
contrast: float = 1.0,
) -> List[List[str]]:
"""
Convert luminance values to ASCII characters.
Args:
luminances: 2D array of luminance values (0-255)
alphabet: Name of character set or custom string
contrast: Contrast multiplier
Returns:
2D list of characters
"""
check_deps()
chars = ASCII_ALPHABETS.get(alphabet, alphabet)
n_chars = len(chars)
rows, cols = luminances.shape
result = []
for r in range(rows):
row_chars = []
for c in range(cols):
lum = luminances[r, c]
# Apply contrast around midpoint
lum = 128 + (lum - 128) * contrast
lum = np.clip(lum, 0, 255)
# Map to character index
idx = int(lum / 256 * n_chars)
idx = min(idx, n_chars - 1)
row_chars.append(chars[idx])
result.append(row_chars)
return result
def render_char_grid(
frame: np.ndarray,
chars: List[List[str]],
colors: np.ndarray,
char_size: int = 8,
color_mode: str = "color",
background: Tuple[int, int, int] = (0, 0, 0),
) -> np.ndarray:
"""
Render character grid to an image.
Args:
frame: Original frame (for dimensions)
chars: 2D list of characters
colors: Color for each cell (rows, cols, 3)
char_size: Size of each character cell
color_mode: "color", "white", or "green"
background: Background RGB color
Returns:
Rendered frame
"""
check_deps()
h, w = frame.shape[:2]
rows = len(chars)
cols = len(chars[0]) if chars else 0
# Create output image
img = Image.new("RGB", (w, h), background)
draw = ImageDraw.Draw(img)
# Try to get a monospace font
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", char_size)
except (IOError, OSError):
try:
font = ImageFont.truetype("/usr/share/fonts/truetype/liberation/LiberationMono-Regular.ttf", char_size)
except (IOError, OSError):
font = ImageFont.load_default()
for r in range(rows):
for c in range(cols):
char = chars[r][c]
if char == ' ':
continue
x = c * char_size
y = r * char_size
if color_mode == "color":
color = tuple(int(v) for v in colors[r, c])
elif color_mode == "green":
color = (0, 255, 0)
else: # white
color = (255, 255, 255)
draw.text((x, y), char, fill=color, font=font)
return np.array(img)
def ascii_art_frame(
frame: np.ndarray,
char_size: int = 8,
alphabet: str = "standard",
color_mode: str = "color",
contrast: float = 1.5,
background: Tuple[int, int, int] = (0, 0, 0),
) -> np.ndarray:
"""
Apply ASCII art effect to a frame.
This is the main entry point for the ascii_art effect.
"""
check_deps()
colors, luminances = cell_sample(frame, char_size)
chars = luminance_to_chars(luminances, alphabet, contrast)
return render_char_grid(frame, chars, colors, char_size, color_mode, background)
# ============================================================================
# ASCII Zones Primitives
# ============================================================================
def ascii_zones_frame(
frame: np.ndarray,
char_size: int = 8,
zone_threshold: int = 128,
dark_chars: str = " .-:",
light_chars: str = "=+*#",
) -> np.ndarray:
"""
Apply zone-based ASCII art effect.
Different character sets for dark vs light regions.
"""
check_deps()
colors, luminances = cell_sample(frame, char_size)
rows, cols = luminances.shape
chars = []
for r in range(rows):
row_chars = []
for c in range(cols):
lum = luminances[r, c]
if lum < zone_threshold:
# Dark zone
charset = dark_chars
local_lum = lum / zone_threshold # 0-1 within zone
else:
# Light zone
charset = light_chars
local_lum = (lum - zone_threshold) / (255 - zone_threshold)
idx = int(local_lum * len(charset))
idx = min(idx, len(charset) - 1)
row_chars.append(charset[idx])
chars.append(row_chars)
return render_char_grid(frame, chars, colors, char_size, "color", (0, 0, 0))
# ============================================================================
# Kaleidoscope Primitives (Python fallback)
# ============================================================================
def kaleidoscope_displace(
w: int,
h: int,
segments: int = 6,
rotation: float = 0,
cx: float = None,
cy: float = None,
zoom: float = 1.0,
) -> Tuple[np.ndarray, np.ndarray]:
"""
Compute kaleidoscope displacement coordinates.
Returns (x_coords, y_coords) arrays for remapping.
"""
check_deps()
if cx is None:
cx = w / 2
if cy is None:
cy = h / 2
# Create coordinate grids
y_grid, x_grid = np.mgrid[0:h, 0:w].astype(np.float32)
# Center coordinates
x_centered = x_grid - cx
y_centered = y_grid - cy
# Convert to polar
r = np.sqrt(x_centered**2 + y_centered**2) / zoom
theta = np.arctan2(y_centered, x_centered)
# Apply rotation
theta = theta - np.radians(rotation)
# Kaleidoscope: fold angle into segment
segment_angle = 2 * np.pi / segments
theta = np.abs(np.mod(theta, segment_angle) - segment_angle / 2)
# Convert back to cartesian
x_new = r * np.cos(theta) + cx
y_new = r * np.sin(theta) + cy
return x_new, y_new
def remap(
frame: np.ndarray,
x_coords: np.ndarray,
y_coords: np.ndarray,
) -> np.ndarray:
"""
Remap frame using coordinate arrays.
Uses bilinear interpolation.
"""
check_deps()
from scipy import ndimage
h, w = frame.shape[:2]
# Clip coordinates
x_coords = np.clip(x_coords, 0, w - 1)
y_coords = np.clip(y_coords, 0, h - 1)
# Remap each channel
if len(frame.shape) == 3:
result = np.zeros_like(frame)
for c in range(frame.shape[2]):
result[:, :, c] = ndimage.map_coordinates(
frame[:, :, c],
[y_coords, x_coords],
order=1,
mode='reflect',
)
return result
else:
return ndimage.map_coordinates(frame, [y_coords, x_coords], order=1, mode='reflect')
def kaleidoscope_frame(
frame: np.ndarray,
segments: int = 6,
rotation: float = 0,
center_x: float = 0.5,
center_y: float = 0.5,
zoom: float = 1.0,
) -> np.ndarray:
"""
Apply kaleidoscope effect to a frame.
This is a Python fallback - FFmpeg version is faster.
"""
check_deps()
h, w = frame.shape[:2]
cx = w * center_x
cy = h * center_y
x_coords, y_coords = kaleidoscope_displace(w, h, segments, rotation, cx, cy, zoom)
return remap(frame, x_coords, y_coords)
# ============================================================================
# Datamosh Primitives (simplified Python version)
# ============================================================================
def datamosh_frame(
frame: np.ndarray,
prev_frame: Optional[np.ndarray],
block_size: int = 32,
corruption: float = 0.3,
max_offset: int = 50,
color_corrupt: bool = True,
) -> np.ndarray:
"""
Simplified datamosh effect using block displacement.
This is a basic approximation - real datamosh works on compressed video.
"""
check_deps()
if prev_frame is None:
return frame.copy()
h, w = frame.shape[:2]
result = frame.copy()
# Process in blocks
for y in range(0, h - block_size, block_size):
for x in range(0, w - block_size, block_size):
if np.random.random() < corruption:
# Random offset
ox = np.random.randint(-max_offset, max_offset + 1)
oy = np.random.randint(-max_offset, max_offset + 1)
# Source from previous frame with offset
src_y = np.clip(y + oy, 0, h - block_size)
src_x = np.clip(x + ox, 0, w - block_size)
block = prev_frame[src_y:src_y+block_size, src_x:src_x+block_size]
# Color corruption
if color_corrupt and np.random.random() < 0.3:
# Swap or shift channels
block = np.roll(block, np.random.randint(1, 3), axis=2)
result[y:y+block_size, x:x+block_size] = block
return result
# ============================================================================
# Pixelsort Primitives (Python version)
# ============================================================================
def pixelsort_frame(
frame: np.ndarray,
sort_by: str = "lightness",
threshold_low: float = 50,
threshold_high: float = 200,
angle: float = 0,
reverse: bool = False,
) -> np.ndarray:
"""
Apply pixel sorting effect to a frame.
"""
check_deps()
from scipy import ndimage
# Rotate if needed
if angle != 0:
frame = ndimage.rotate(frame, -angle, reshape=False, mode='reflect')
h, w = frame.shape[:2]
result = frame.copy()
# Compute sort key
if sort_by == "lightness":
key = 0.299 * frame[:,:,0] + 0.587 * frame[:,:,1] + 0.114 * frame[:,:,2]
elif sort_by == "hue":
# Simple hue approximation
key = np.arctan2(
np.sqrt(3) * (frame[:,:,1].astype(float) - frame[:,:,2]),
2 * frame[:,:,0].astype(float) - frame[:,:,1] - frame[:,:,2]
)
elif sort_by == "saturation":
mx = frame.max(axis=2).astype(float)
mn = frame.min(axis=2).astype(float)
key = np.where(mx > 0, (mx - mn) / mx, 0)
else:
key = frame[:,:,0] # Red channel
# Sort each row
for y in range(h):
row = result[y]
row_key = key[y]
# Find sortable intervals (pixels within threshold)
mask = (row_key >= threshold_low) & (row_key <= threshold_high)
# Find runs of True in mask
runs = []
start = None
for x in range(w):
if mask[x] and start is None:
start = x
elif not mask[x] and start is not None:
if x - start > 1:
runs.append((start, x))
start = None
if start is not None and w - start > 1:
runs.append((start, w))
# Sort each run
for start, end in runs:
indices = np.argsort(row_key[start:end])
if reverse:
indices = indices[::-1]
result[y, start:end] = row[start:end][indices]
# Rotate back
if angle != 0:
result = ndimage.rotate(result, angle, reshape=False, mode='reflect')
return result
# ============================================================================
# Primitive Registry
# ============================================================================
def map_char_grid(
chars,
luminances,
fn,
):
"""
Map a function over each cell of a character grid.
Args:
chars: 2D array/list of characters (rows, cols)
luminances: 2D array of luminance values
fn: Function or Lambda (row, col, char, luminance) -> new_char
Returns:
New character grid with mapped values (list of lists)
"""
from .parser import Lambda
from .evaluator import evaluate
# Handle both list and numpy array inputs
if isinstance(chars, np.ndarray):
rows, cols = chars.shape[:2]
else:
rows = len(chars)
cols = len(chars[0]) if rows > 0 and isinstance(chars[0], (list, tuple, str)) else 1
# Get luminances as 2D
if isinstance(luminances, np.ndarray):
lum_arr = luminances
else:
lum_arr = np.array(luminances)
# Check if fn is a Lambda (from sexp) or a Python callable
is_lambda = isinstance(fn, Lambda)
result = []
for r in range(rows):
row_result = []
for c in range(cols):
# Get character
if isinstance(chars, np.ndarray):
ch = chars[r, c] if len(chars.shape) > 1 else chars[r]
elif isinstance(chars[r], str):
ch = chars[r][c] if c < len(chars[r]) else ' '
else:
ch = chars[r][c] if c < len(chars[r]) else ' '
# Get luminance
if len(lum_arr.shape) > 1:
lum = lum_arr[r, c]
else:
lum = lum_arr[r]
# Call the function
if is_lambda:
# Evaluate the Lambda with arguments bound
call_env = dict(fn.closure) if fn.closure else {}
for param, val in zip(fn.params, [r, c, ch, float(lum)]):
call_env[param] = val
new_ch = evaluate(fn.body, call_env)
else:
new_ch = fn(r, c, ch, float(lum))
row_result.append(new_ch)
result.append(row_result)
return result
def alphabet_char(alphabet: str, index: int) -> str:
"""
Get a character from an alphabet at a given index.
Args:
alphabet: Alphabet name (from ASCII_ALPHABETS) or literal string
index: Index into the alphabet (clamped to valid range)
Returns:
Character at the index
"""
# Get alphabet string
if alphabet in ASCII_ALPHABETS:
chars = ASCII_ALPHABETS[alphabet]
else:
chars = alphabet
# Clamp index to valid range
index = int(index)
index = max(0, min(index, len(chars) - 1))
return chars[index]
PRIMITIVES = {
# ASCII
"cell-sample": cell_sample,
"luminance-to-chars": luminance_to_chars,
"render-char-grid": render_char_grid,
"map-char-grid": map_char_grid,
"alphabet-char": alphabet_char,
"ascii_art_frame": ascii_art_frame,
"ascii_zones_frame": ascii_zones_frame,
# Kaleidoscope
"kaleidoscope-displace": kaleidoscope_displace,
"remap": remap,
"kaleidoscope_frame": kaleidoscope_frame,
# Datamosh
"datamosh": datamosh_frame,
"datamosh_frame": datamosh_frame,
# Pixelsort
"pixelsort": pixelsort_frame,
"pixelsort_frame": pixelsort_frame,
}
def get_primitive(name: str):
"""Get a primitive function by name."""
return PRIMITIVES.get(name)
def list_primitives() -> List[str]:
"""List all available primitives."""
return list(PRIMITIVES.keys())