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

135 lines
3.7 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect tile_grid
@version 1.0.0
@author artdag
@description
Tile Grid effect. Repeats the frame in a grid pattern creating
a mosaic by tiling scaled-down copies. Great for psychedelic visuals.
@param rows int
@range 1 10
@default 2
Number of rows in grid.
@param cols int
@range 1 10
@default 2
Number of columns in grid.
@param gap int
@range 0 50
@default 0
Gap between tiles in pixels.
@param gap_color list
@default [0, 0, 0]
RGB color for gaps.
@param rotation_per_tile float
@range -180 180
@default 0
Rotation increment per tile in degrees.
@param alternate_flip bool
@default false
Flip alternating tiles horizontally.
@example
(effect tile_grid :rows 3 :cols 3)
@example
;; Rotating tiles
(effect tile_grid :rows 2 :cols 2 :rotation_per_tile 90)
@example
;; Beat-reactive grid
(effect tile_grid :rows (bind bass :range [2 6]) :cols (bind bass :range [2 6]))
"""
import numpy as np
import cv2
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
"""
Apply tile grid effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
- rows: number of rows (default 2)
- cols: number of columns (default 2)
- gap: gap between tiles (default 0)
- gap_color: RGB tuple (default [0,0,0])
- rotation_per_tile: rotation increment (default 0)
- alternate_flip: flip alternating tiles (default False)
state: Persistent state dict
Returns:
Tuple of (processed_frame, new_state)
"""
rows = max(1, min(int(params.get("rows", 2)), 10))
cols = max(1, min(int(params.get("cols", 2)), 10))
gap = max(0, int(params.get("gap", 0)))
gap_color = params.get("gap_color", [0, 0, 0])
rotation_per_tile = params.get("rotation_per_tile", 0)
alternate_flip = params.get("alternate_flip", False)
if state is None:
state = {}
h, w = frame.shape[:2]
# Calculate tile size
tile_w = (w - gap * (cols - 1)) // cols
tile_h = (h - gap * (rows - 1)) // rows
if tile_w <= 0 or tile_h <= 0:
return frame, state
# Scale down the original frame to tile size
tile = cv2.resize(frame, (tile_w, tile_h), interpolation=cv2.INTER_LINEAR)
# Create result with gap color
if isinstance(gap_color, (list, tuple)) and len(gap_color) >= 3:
result = np.full((h, w, 3), gap_color[:3], dtype=np.uint8)
else:
result = np.zeros((h, w, 3), dtype=np.uint8)
# Place tiles
tile_idx = 0
for row in range(rows):
for col in range(cols):
y = row * (tile_h + gap)
x = col * (tile_w + gap)
current_tile = tile.copy()
# Apply rotation if specified
if rotation_per_tile != 0:
angle = rotation_per_tile * tile_idx
center = (tile_w // 2, tile_h // 2)
M = cv2.getRotationMatrix2D(center, angle, 1.0)
current_tile = cv2.warpAffine(current_tile, M, (tile_w, tile_h),
borderMode=cv2.BORDER_REFLECT)
# Apply flip for alternating tiles
if alternate_flip and (row + col) % 2 == 1:
current_tile = cv2.flip(current_tile, 1)
# Place tile
y_end = min(y + tile_h, h)
x_end = min(x + tile_w, w)
tile_crop_h = y_end - y
tile_crop_w = x_end - x
result[y:y_end, x:x_end] = current_tile[:tile_crop_h, :tile_crop_w]
tile_idx += 1
return result, state