Files
test/effects/kaleidoscope.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

138 lines
3.6 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect kaleidoscope
@version 1.0.0
@author artdag
@description
Kaleidoscope effect. Creates mesmerizing mandala-like patterns by
dividing the frame into pie-slice segments and reflecting them.
Great for psychedelic visuals.
@param segments int
@range 3 16
@default 6
Number of symmetry segments.
@param rotation float
@range 0 360
@default 0
Base rotation angle in degrees.
@param rotation_speed float
@range -180 180
@default 0
Continuous rotation speed in degrees/second.
@param center_x float
@range 0 1
@default 0.5
Center X position (0-1).
@param center_y float
@range 0 1
@default 0.5
Center Y position (0-1).
@param zoom float
@range 0.5 3.0
@default 1.0
Zoom factor for the source region.
@state cumulative_rotation float
Tracks rotation over time.
@example
(effect kaleidoscope :segments 8 :rotation_speed 30)
@example
;; Beat-reactive segments
(effect kaleidoscope :segments (bind bass :range [4 12]) :zoom 1.5)
"""
import numpy as np
import cv2
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
"""
Apply kaleidoscope effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
- segments: number of segments 3-16 (default 6)
- rotation: base rotation degrees (default 0)
- rotation_speed: degrees per second (default 0)
- center_x: center X 0-1 (default 0.5)
- center_y: center Y 0-1 (default 0.5)
- zoom: zoom factor 0.5-3 (default 1.0)
state: Persistent state dict
Returns:
Tuple of (processed_frame, new_state)
"""
segments = max(3, min(int(params.get("segments", 6)), 16))
rotation = params.get("rotation", 0)
rotation_speed = params.get("rotation_speed", 0)
center_x = params.get("center_x", 0.5)
center_y = params.get("center_y", 0.5)
zoom = max(0.5, min(params.get("zoom", 1.0), 3.0))
# Get time for animation
t = params.get("_time", 0)
if state is None:
state = {}
h, w = frame.shape[:2]
# Calculate center in pixels
cx = int(w * center_x)
cy = int(h * center_y)
# Total rotation including time-based animation
total_rotation = rotation + rotation_speed * t
# Calculate the angle per segment
segment_angle = 2 * np.pi / segments
# Create coordinate maps
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 coordinates
r = np.sqrt(x_centered**2 + y_centered**2)
theta = np.arctan2(y_centered, x_centered)
# Apply rotation
theta = theta - np.deg2rad(total_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 (source coordinates)
src_x = (r * np.cos(theta_in_segment) + cx).astype(np.float32)
src_y = (r * np.sin(theta_in_segment) + cy).astype(np.float32)
# Remap
result = cv2.remap(frame, src_x, src_y,
cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
return result, state