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

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

116 lines
3.5 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect chromatic
@version 1.0.0
@author artdag
@description
Chromatic aberration effect. Creates color fringing by offsetting
RGB channels radially from the center (lens distortion simulation).
@param strength float
@range 0 50
@default 10
Aberration strength. Bind to bass for reactive effect.
@param center_x float
@range 0 1
@default 0.5
Aberration center X.
@param center_y float
@range 0 1
@default 0.5
Aberration center Y.
@param radial bool
@default true
If true, aberration increases from center to edges.
@example
(effect chromatic :strength 20)
@example
;; Beat-reactive chromatic aberration
(effect chromatic :strength (bind bass :range [0 30]))
"""
import numpy as np
import cv2
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
"""
Apply chromatic aberration effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
- strength: aberration amount (default 10)
- center_x: center X 0-1 (default 0.5)
- center_y: center Y 0-1 (default 0.5)
- radial: increase from center (default True)
state: Persistent state dict
Returns:
Tuple of (processed_frame, new_state)
"""
strength = params.get("strength", 10)
center_x = params.get("center_x", 0.5)
center_y = params.get("center_y", 0.5)
radial = params.get("radial", True)
if state is None:
state = {}
if strength == 0:
return frame, state
h, w = frame.shape[:2]
r, g, b = frame[:, :, 0], frame[:, :, 1], frame[:, :, 2]
if radial:
# Create distance-from-center map
y_coords, x_coords = np.ogrid[:h, :w]
cx, cy = w * center_x, h * center_y
dist = np.sqrt((x_coords - cx)**2 + (y_coords - cy)**2)
max_dist = np.sqrt(cx**2 + cy**2)
dist_normalized = (dist / max_dist).astype(np.float32)
# Create coordinate maps for remapping
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))
# Direction from center
dx = (map_x - cx) / (dist + 1e-6)
dy = (map_y - cy) / (dist + 1e-6)
# Apply radial offset to red channel (outward)
r_offset = strength * dist_normalized
r_map_x = (map_x + dx * r_offset).astype(np.float32)
r_map_y = (map_y + dy * r_offset).astype(np.float32)
r_shifted = cv2.remap(r, r_map_x, r_map_y,
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
# Apply radial offset to blue channel (inward)
b_offset = -strength * dist_normalized
b_map_x = (map_x + dx * b_offset).astype(np.float32)
b_map_y = (map_y + dy * b_offset).astype(np.float32)
b_shifted = cv2.remap(b, b_map_x, b_map_y,
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
return np.stack([r_shifted, g, b_shifted], axis=-1).astype(np.uint8), state
else:
# Simple uniform offset
offset = int(strength)
M_r = np.float32([[1, 0, offset], [0, 1, 0]])
M_b = np.float32([[1, 0, -offset], [0, 1, 0]])
r_shifted = cv2.warpAffine(r, M_r, (w, h), borderMode=cv2.BORDER_REPLICATE)
b_shifted = cv2.warpAffine(b, M_b, (w, h), borderMode=cv2.BORDER_REPLICATE)
return np.stack([r_shifted, g, b_shifted], axis=-1).astype(np.uint8), state