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

149 lines
4.4 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect film_grain
@version 1.0.0
@author artdag
@description
Film grain / Noise effect. Adds realistic film grain texture.
Great for vintage aesthetics and subtle texture.
@param intensity float
@range 0 1
@default 0.2
Noise intensity. Bind to energy for reactive grain.
@param grain_size float
@range 0.5 5
@default 1.0
Size of grain particles. Larger = coarser grain.
@param colored bool
@default false
Use colored noise instead of monochrome.
@param temporal_variation float
@range 0 1
@default 1.0
How much grain changes frame-to-frame. 0 = static, 1 = full animation.
@param seed int
@default 42
Random seed for reproducible grain.
@state rng DeterministicRNG
Random number generator for consistent grain.
@example
(effect film_grain :intensity 0.3)
@example
;; Colored grain, reactive to energy
(effect film_grain :intensity (bind energy :range [0.1 0.5]) :colored true)
"""
import numpy as np
import cv2
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 film grain effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
- intensity: noise intensity 0-1 (default 0.2)
- grain_size: grain particle size (default 1.0)
- colored: use colored noise (default False)
- temporal_variation: grain animation 0-1 (default 1.0)
- seed: random seed (default 42)
state: Persistent state dict
- rng: DeterministicRNG instance
- frame_count: frame counter
Returns:
Tuple of (processed_frame, new_state)
"""
intensity = params.get("intensity", 0.2)
grain_size = max(0.5, params.get("grain_size", 1.0))
colored = params.get("colored", False)
temporal_var = params.get("temporal_variation", 1.0)
seed = int(params.get("seed", 42))
if state is None:
state = {}
if intensity <= 0:
return frame, state
# Initialize RNG
if "rng" not in state:
state["rng"] = DeterministicRNG(seed)
state["frame_count"] = 0
state["static_noise"] = None
rng = state["rng"]
frame_count = state["frame_count"]
h, w = frame.shape[:2]
# Calculate noise dimensions
if grain_size > 1:
noise_h = max(1, int(h / grain_size))
noise_w = max(1, int(w / grain_size))
else:
noise_h, noise_w = h, w
# Generate noise based on temporal variation
if temporal_var >= 1.0 or state.get("static_noise") is None:
# Generate new noise
if colored:
noise = np.array([[[rng.gaussian(0, 1) for _ in range(3)]
for _ in range(noise_w)]
for _ in range(noise_h)]) * intensity * 50
else:
noise_2d = np.array([[rng.gaussian(0, 1)
for _ in range(noise_w)]
for _ in range(noise_h)]) * intensity * 50
noise = np.stack([noise_2d, noise_2d, noise_2d], axis=-1)
if temporal_var < 1.0:
state["static_noise"] = noise
else:
# Blend static and new noise
static = state["static_noise"]
if colored:
new_noise = np.array([[[rng.gaussian(0, 1) for _ in range(3)]
for _ in range(noise_w)]
for _ in range(noise_h)]) * intensity * 50
else:
noise_2d = np.array([[rng.gaussian(0, 1)
for _ in range(noise_w)]
for _ in range(noise_h)]) * intensity * 50
new_noise = np.stack([noise_2d, noise_2d, noise_2d], axis=-1)
noise = static * (1 - temporal_var) + new_noise * temporal_var
# Scale noise up if using larger grain
if grain_size > 1:
noise = cv2.resize(noise.astype(np.float32), (w, h), interpolation=cv2.INTER_LINEAR)
# Add noise to frame
result = frame.astype(np.float32) + noise
state["frame_count"] = frame_count + 1
return np.clip(result, 0, 255).astype(np.uint8), state