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

140 lines
3.7 KiB
Python

# /// script
# requires-python = ">=3.10"
# dependencies = ["numpy", "opencv-python"]
# ///
"""
@effect color_grade
@version 1.0.0
@author artdag
@description
Color grading effect. Applies cinematic color adjustments including
shadows/midtones/highlights tinting, lift/gamma/gain, and temperature.
@param shadows list
@default [0, 0, 0]
RGB tint for dark areas.
@param midtones list
@default [0, 0, 0]
RGB tint for middle tones.
@param highlights list
@default [0, 0, 0]
RGB tint for bright areas.
@param lift float
@range -0.5 0.5
@default 0
Raise/lower shadow levels.
@param gamma float
@range 0.5 2
@default 1
Midtone brightness curve.
@param gain float
@range 0.5 2
@default 1
Highlight intensity.
@param temperature float
@range -100 100
@default 0
Color temperature (-100 = cool/blue, +100 = warm/orange).
@param tint float
@range -100 100
@default 0
Green/magenta tint (-100 = green, +100 = magenta).
@example
(effect color_grade :temperature 30 :shadows [0 0 20])
@example
;; Cinematic teal-orange look
(effect color_grade :shadows [0 10 20] :highlights [20 10 0])
"""
import numpy as np
import cv2
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
"""
Apply color grading 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)
"""
shadows = params.get("shadows", [0, 0, 0])
midtones = params.get("midtones", [0, 0, 0])
highlights = params.get("highlights", [0, 0, 0])
lift = params.get("lift", 0)
gamma = max(0.5, min(params.get("gamma", 1), 2))
gain = max(0.5, min(params.get("gain", 1), 2))
temperature = params.get("temperature", 0)
tint = params.get("tint", 0)
if state is None:
state = {}
result = frame.astype(np.float32) / 255.0
# Apply lift (shadows)
result = result + lift
# Apply gamma (midtones)
result = np.power(np.clip(result, 0.001, 1), 1 / gamma)
# Apply gain (highlights)
result = result * gain
# Convert tints to float
if isinstance(shadows, (list, tuple)) and len(shadows) >= 3:
shadows = np.array(shadows[:3], dtype=np.float32) / 255.0
else:
shadows = np.zeros(3, dtype=np.float32)
if isinstance(midtones, (list, tuple)) and len(midtones) >= 3:
midtones = np.array(midtones[:3], dtype=np.float32) / 255.0
else:
midtones = np.zeros(3, dtype=np.float32)
if isinstance(highlights, (list, tuple)) and len(highlights) >= 3:
highlights = np.array(highlights[:3], dtype=np.float32) / 255.0
else:
highlights = np.zeros(3, dtype=np.float32)
# Calculate luminance for zone-based grading
lum = 0.299 * result[:, :, 0] + 0.587 * result[:, :, 1] + 0.114 * result[:, :, 2]
# Create zone masks
shadow_mask = np.clip(1 - lum * 3, 0, 1)[:, :, np.newaxis]
highlight_mask = np.clip((lum - 0.67) * 3, 0, 1)[:, :, np.newaxis]
midtone_mask = 1 - shadow_mask - highlight_mask
# Apply zone tints
for c in range(3):
result[:, :, c] += shadows[c] * shadow_mask[:, :, 0]
result[:, :, c] += midtones[c] * midtone_mask[:, :, 0]
result[:, :, c] += highlights[c] * highlight_mask[:, :, 0]
# Apply temperature (blue <-> orange)
if temperature != 0:
temp_shift = temperature / 100.0
result[:, :, 0] += temp_shift * 0.3 # Red
result[:, :, 2] -= temp_shift * 0.3 # Blue
# Apply tint (green <-> magenta)
if tint != 0:
tint_shift = tint / 100.0
result[:, :, 1] -= tint_shift * 0.2 # Green
return (np.clip(result, 0, 1) * 255).astype(np.uint8), state