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

143 lines
3.9 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "scipy"]
# ///
"""
@effect scatter
@version 1.0.0
@author artdag
@description
Scatter effect. Randomly redistributes pixels in local area creating
a dissolving, dispersed look.
@param amount float
@range 0 100
@default 10
Scatter radius in pixels. Bind to energy for reactive dissolve.
@param randomize_per_frame bool
@default false
Different scatter pattern each frame (vs. static pattern).
@param grain float
@range 0 1
@default 0
Add film grain to scattered result.
@param seed int
@default 42
Random seed for reproducible patterns.
@state rng DeterministicRNG
Random number generator for displacement.
@state displacement_map ndarray
Cached displacement map for static mode.
@example
(effect scatter :amount 20)
@example
;; Reactive dissolve
(effect scatter :amount (bind energy :range [5 50]) :randomize_per_frame true)
"""
import numpy as np
from scipy import ndimage
from pathlib import Path
import sys
# Import DeterministicRNG from same directory
_effects_dir = Path(__file__).parent
if str(_effects_dir) not in sys.path:
sys.path.insert(0, str(_effects_dir))
from random import DeterministicRNG
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
"""
Apply scatter effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
- amount: scatter radius (default 10)
- randomize_per_frame: animate pattern (default False)
- grain: add film grain (default 0)
- seed: random seed (default 42)
state: Persistent state dict
Returns:
Tuple of (processed_frame, new_state)
"""
amount = int(np.clip(params.get("amount", 10), 0, 100))
randomize = params.get("randomize_per_frame", False)
grain = params.get("grain", 0)
seed = int(params.get("seed", 42))
if state is None:
state = {}
if amount < 1:
return frame, state
# Initialize RNG
if "rng" not in state:
state["rng"] = DeterministicRNG(seed)
rng = state["rng"]
h, w = frame.shape[:2]
# Generate or reuse displacement map
last_size = state.get("last_size")
if randomize or "displacement_map" not in state or last_size != (h, w):
# Generate new displacement map
displacement = np.zeros((h, w, 2), dtype=np.float32)
for y in range(h):
for x in range(w):
displacement[y, x, 0] = rng.uniform(-amount, amount)
displacement[y, x, 1] = rng.uniform(-amount, amount)
state["displacement_map"] = displacement
state["last_size"] = (h, w)
displacement_map = state["displacement_map"]
# Create coordinate grids
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
# Apply displacement
new_y = y_coords + displacement_map[:, :, 0]
new_x = x_coords + displacement_map[:, :, 1]
# Clip to valid range
new_y = np.clip(new_y, 0, h - 1)
new_x = np.clip(new_x, 0, w - 1)
# Sample from displaced positions
result = np.zeros_like(frame)
for c in range(frame.shape[2] if len(frame.shape) > 2 else 1):
if len(frame.shape) > 2:
result[:, :, c] = ndimage.map_coordinates(
frame[:, :, c],
[new_y, new_x],
order=1,
mode='reflect'
)
else:
result = ndimage.map_coordinates(
frame,
[new_y, new_x],
order=1,
mode='reflect'
)
# Add grain
if grain > 0:
noise = np.array([[[rng.uniform(-grain * 20, grain * 20) for _ in range(3)]
for _ in range(w)]
for _ in range(h)])
result = np.clip(result.astype(np.float32) + noise, 0, 255).astype(np.uint8)
return result, state