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>
This commit is contained in:
142
effects/scatter.py
Normal file
142
effects/scatter.py
Normal file
@@ -0,0 +1,142 @@
|
||||
# /// 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
|
||||
Reference in New Issue
Block a user