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

194 lines
5.1 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect crt
@version 1.0.0
@author artdag
@description
CRT / Scanlines effect. Simulates CRT monitor aesthetics with visible
scan lines, optional RGB subpixels, barrel distortion, and vignette.
@param line_spacing int
@range 1 10
@default 2
Pixels between scanlines.
@param line_opacity float
@range 0 1
@default 0.3
Darkness of scanlines.
@param rgb_subpixels bool
@default false
Show RGB subpixel pattern.
@param curvature float
@range 0 0.5
@default 0
Barrel distortion amount for curved screen look.
@param vignette float
@range 0 1
@default 0
Dark corners effect.
@param bloom float
@range 0 1
@default 0
Glow/blur on bright areas.
@param flicker float
@range 0 0.3
@default 0
Brightness variation.
@param seed int
@default 42
Random seed for flicker.
@state rng DeterministicRNG
Random number generator for flicker.
@example
(effect crt :line_spacing 2 :line_opacity 0.4)
@example
;; Full retro CRT look
(effect crt :curvature 0.2 :vignette 0.3 :rgb_subpixels true :bloom 0.2)
"""
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 CRT effect to a video frame.
Args:
frame: Input frame as numpy array (H, W, 3) RGB uint8
params: Effect parameters
state: Persistent state dict
Returns:
Tuple of (processed_frame, new_state)
"""
line_spacing = max(1, int(params.get("line_spacing", 2)))
line_opacity = params.get("line_opacity", 0.3)
rgb_subpixels = params.get("rgb_subpixels", False)
curvature = params.get("curvature", 0)
vignette = params.get("vignette", 0)
bloom = params.get("bloom", 0)
flicker = params.get("flicker", 0)
seed = int(params.get("seed", 42))
if state is None:
state = {}
# Initialize RNG
if "rng" not in state:
state["rng"] = DeterministicRNG(seed)
rng = state["rng"]
h, w = frame.shape[:2]
result = frame.astype(np.float32).copy()
# Apply barrel distortion (curvature)
if curvature > 0:
result = _apply_curvature(result, curvature)
# Apply bloom (glow on bright areas)
if bloom > 0:
result = _apply_bloom(result, bloom)
# Apply scanlines
if line_opacity > 0:
for y in range(0, h, line_spacing):
result[y, :] = result[y, :] * (1 - line_opacity)
# Apply RGB subpixel pattern
if rgb_subpixels:
for x in range(w):
col_type = x % 3
if col_type == 0:
result[:, x, 0] *= 1.2
result[:, x, 1] *= 0.8
result[:, x, 2] *= 0.8
elif col_type == 1:
result[:, x, 0] *= 0.8
result[:, x, 1] *= 1.2
result[:, x, 2] *= 0.8
else:
result[:, x, 0] *= 0.8
result[:, x, 1] *= 0.8
result[:, x, 2] *= 1.2
# Apply vignette
if vignette > 0:
y_coords, x_coords = np.ogrid[:h, :w]
center_x, center_y = w / 2, h / 2
dist = np.sqrt((x_coords - center_x)**2 + (y_coords - center_y)**2)
max_dist = np.sqrt(center_x**2 + center_y**2)
vignette_mask = 1 - (dist / max_dist) * vignette
vignette_mask = np.clip(vignette_mask, 0, 1)
result = result * vignette_mask[:, :, np.newaxis]
# Apply flicker
if flicker > 0:
flicker_amount = 1.0 + rng.uniform(-flicker, flicker)
result = result * flicker_amount
return np.clip(result, 0, 255).astype(np.uint8), state
def _apply_curvature(frame: np.ndarray, strength: float) -> np.ndarray:
"""Apply barrel distortion."""
h, w = frame.shape[:2]
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
# Normalize to -1 to 1
x_norm = (x_coords - w / 2) / (w / 2)
y_norm = (y_coords - h / 2) / (h / 2)
# Calculate radius
r = np.sqrt(x_norm**2 + y_norm**2)
# Apply barrel distortion
r_distorted = r * (1 + strength * r**2)
# Scale factor
scale = np.where(r > 0, r_distorted / r, 1)
# New coordinates
new_x = (x_norm * scale * (w / 2) + w / 2).astype(np.float32)
new_y = (y_norm * scale * (h / 2) + h / 2).astype(np.float32)
result = cv2.remap(frame.astype(np.uint8), new_x, new_y,
cv2.INTER_LINEAR, borderMode=cv2.BORDER_CONSTANT,
borderValue=(0, 0, 0))
return result.astype(np.float32)
def _apply_bloom(frame: np.ndarray, strength: float) -> np.ndarray:
"""Apply bloom (glow on bright areas)."""
gray = cv2.cvtColor(frame.astype(np.uint8), cv2.COLOR_RGB2GRAY)
_, bright = cv2.threshold(gray, 200, 255, cv2.THRESH_BINARY)
bloom = cv2.GaussianBlur(bright, (21, 21), 0)
bloom = cv2.cvtColor(bloom, cv2.COLOR_GRAY2RGB)
result = frame + bloom.astype(np.float32) * strength * 0.5
return result