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>
153 lines
4.2 KiB
Python
153 lines
4.2 KiB
Python
# /// script
|
|
# requires-python = ">=3.10"
|
|
# dependencies = ["numpy", "opencv-python"]
|
|
# ///
|
|
"""
|
|
@effect pixelsort
|
|
@version 1.0.0
|
|
@author artdag
|
|
|
|
@description
|
|
Pixel sorting glitch art effect. Sorts pixels within rows by brightness,
|
|
hue, or other properties. Creates distinctive streaked/melted aesthetics.
|
|
|
|
@param sort_by string
|
|
@enum lightness hue saturation red green blue
|
|
@default lightness
|
|
Property to sort pixels by.
|
|
|
|
@param threshold_low float
|
|
@range 0 255
|
|
@default 50
|
|
Pixels darker than this are not sorted.
|
|
|
|
@param threshold_high float
|
|
@range 0 255
|
|
@default 200
|
|
Pixels brighter than this are not sorted.
|
|
|
|
@param angle float
|
|
@range 0 180
|
|
@default 0
|
|
Sort direction: 0 = horizontal, 90 = vertical.
|
|
|
|
@param reverse bool
|
|
@default false
|
|
Reverse the sort order.
|
|
|
|
@example
|
|
(effect pixelsort)
|
|
|
|
@example
|
|
;; Vertical pixel sort
|
|
(effect pixelsort :angle 90)
|
|
|
|
@example
|
|
;; Sort by hue for rainbow streaks
|
|
(effect pixelsort :sort_by "hue" :threshold_low 20 :threshold_high 240)
|
|
"""
|
|
|
|
import numpy as np
|
|
import cv2
|
|
|
|
|
|
def process_frame(frame: np.ndarray, params: dict, state: dict) -> tuple:
|
|
"""
|
|
Apply pixel sorting to a video frame.
|
|
|
|
Args:
|
|
frame: Input frame as numpy array (H, W, 3) RGB uint8
|
|
params: Effect parameters
|
|
- sort_by: property to sort by (default "lightness")
|
|
- threshold_low: min brightness to sort (default 50)
|
|
- threshold_high: max brightness to sort (default 200)
|
|
- angle: 0 = horizontal, 90 = vertical (default 0)
|
|
- reverse: reverse sort order (default False)
|
|
state: Persistent state dict (unused)
|
|
|
|
Returns:
|
|
Tuple of (processed_frame, new_state)
|
|
"""
|
|
sort_by = params.get("sort_by", "lightness")
|
|
threshold_low = params.get("threshold_low", 50)
|
|
threshold_high = params.get("threshold_high", 200)
|
|
angle = params.get("angle", 0)
|
|
reverse = params.get("reverse", False)
|
|
|
|
h, w = frame.shape[:2]
|
|
|
|
# Rotate for non-horizontal sorting
|
|
if 45 <= (angle % 180) <= 135:
|
|
frame = np.transpose(frame, (1, 0, 2))
|
|
h, w = frame.shape[:2]
|
|
rotated = True
|
|
else:
|
|
rotated = False
|
|
|
|
result = frame.copy()
|
|
|
|
# Get sort values
|
|
sort_values = _get_sort_values(frame, sort_by)
|
|
|
|
# Create mask of pixels to sort
|
|
mask = (sort_values >= threshold_low) & (sort_values <= threshold_high)
|
|
|
|
# Sort each row
|
|
for y in range(h):
|
|
row = result[y].copy()
|
|
row_mask = mask[y]
|
|
row_values = sort_values[y]
|
|
|
|
# Find contiguous segments to sort
|
|
segments = _find_segments(row_mask)
|
|
|
|
for start, end in segments:
|
|
if end - start > 1:
|
|
segment_values = row_values[start:end]
|
|
sort_indices = np.argsort(segment_values)
|
|
if reverse:
|
|
sort_indices = sort_indices[::-1]
|
|
row[start:end] = row[start:end][sort_indices]
|
|
|
|
result[y] = row
|
|
|
|
# Rotate back if needed
|
|
if rotated:
|
|
result = np.transpose(result, (1, 0, 2))
|
|
|
|
return np.ascontiguousarray(result), state
|
|
|
|
|
|
def _get_sort_values(frame, sort_by):
|
|
"""Get values to sort pixels by."""
|
|
if sort_by == "lightness":
|
|
return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
|
elif sort_by == "hue":
|
|
hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV)
|
|
return hsv[:, :, 0].astype(np.float32)
|
|
elif sort_by == "saturation":
|
|
hsv = cv2.cvtColor(frame, cv2.COLOR_RGB2HSV)
|
|
return hsv[:, :, 1].astype(np.float32)
|
|
elif sort_by == "red":
|
|
return frame[:, :, 0].astype(np.float32)
|
|
elif sort_by == "green":
|
|
return frame[:, :, 1].astype(np.float32)
|
|
elif sort_by == "blue":
|
|
return frame[:, :, 2].astype(np.float32)
|
|
return cv2.cvtColor(frame, cv2.COLOR_RGB2GRAY).astype(np.float32)
|
|
|
|
|
|
def _find_segments(mask):
|
|
"""Find contiguous True segments in mask."""
|
|
segments = []
|
|
start = None
|
|
for i, val in enumerate(mask):
|
|
if val and start is None:
|
|
start = i
|
|
elif not val and start is not None:
|
|
segments.append((start, i))
|
|
start = None
|
|
if start is not None:
|
|
segments.append((start, len(mask)))
|
|
return segments
|