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

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