Import L1 (celery) as l1/
This commit is contained in:
32
l1/sexp_effects/__init__.py
Normal file
32
l1/sexp_effects/__init__.py
Normal file
@@ -0,0 +1,32 @@
|
||||
"""
|
||||
S-Expression Effects System
|
||||
|
||||
Safe, shareable effects defined in S-expressions.
|
||||
"""
|
||||
|
||||
from .parser import parse, parse_file, Symbol, Keyword
|
||||
from .interpreter import (
|
||||
Interpreter,
|
||||
get_interpreter,
|
||||
load_effect,
|
||||
load_effects_dir,
|
||||
run_effect,
|
||||
list_effects,
|
||||
make_process_frame,
|
||||
)
|
||||
from .primitives import PRIMITIVES
|
||||
|
||||
__all__ = [
|
||||
'parse',
|
||||
'parse_file',
|
||||
'Symbol',
|
||||
'Keyword',
|
||||
'Interpreter',
|
||||
'get_interpreter',
|
||||
'load_effect',
|
||||
'load_effects_dir',
|
||||
'run_effect',
|
||||
'list_effects',
|
||||
'make_process_frame',
|
||||
'PRIMITIVES',
|
||||
]
|
||||
206
l1/sexp_effects/derived.sexp
Normal file
206
l1/sexp_effects/derived.sexp
Normal file
@@ -0,0 +1,206 @@
|
||||
;; Derived Operations
|
||||
;;
|
||||
;; These are built from true primitives using S-expressions.
|
||||
;; Load with: (require "derived")
|
||||
|
||||
;; =============================================================================
|
||||
;; Math Helpers (derivable from where + basic ops)
|
||||
;; =============================================================================
|
||||
|
||||
;; Absolute value
|
||||
(define (abs x) (where (< x 0) (- x) x))
|
||||
|
||||
;; Minimum of two values
|
||||
(define (min2 a b) (where (< a b) a b))
|
||||
|
||||
;; Maximum of two values
|
||||
(define (max2 a b) (where (> a b) a b))
|
||||
|
||||
;; Clamp x to range [lo, hi]
|
||||
(define (clamp x lo hi) (max2 lo (min2 hi x)))
|
||||
|
||||
;; Square of x
|
||||
(define (sq x) (* x x))
|
||||
|
||||
;; Linear interpolation: a*(1-t) + b*t
|
||||
(define (lerp a b t) (+ (* a (- 1 t)) (* b t)))
|
||||
|
||||
;; Smooth interpolation between edges
|
||||
(define (smoothstep edge0 edge1 x)
|
||||
(let ((t (clamp (/ (- x edge0) (- edge1 edge0)) 0 1)))
|
||||
(* t (* t (- 3 (* 2 t))))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Channel Shortcuts (derivable from channel primitive)
|
||||
;; =============================================================================
|
||||
|
||||
;; Extract red channel as xector
|
||||
(define (red frame) (channel frame 0))
|
||||
|
||||
;; Extract green channel as xector
|
||||
(define (green frame) (channel frame 1))
|
||||
|
||||
;; Extract blue channel as xector
|
||||
(define (blue frame) (channel frame 2))
|
||||
|
||||
;; Convert to grayscale xector (ITU-R BT.601)
|
||||
(define (gray frame)
|
||||
(+ (* (red frame) 0.299)
|
||||
(* (green frame) 0.587)
|
||||
(* (blue frame) 0.114)))
|
||||
|
||||
;; Alias for gray
|
||||
(define (luminance frame) (gray frame))
|
||||
|
||||
;; =============================================================================
|
||||
;; Coordinate Generators (derivable from iota + repeat/tile)
|
||||
;; =============================================================================
|
||||
|
||||
;; X coordinate for each pixel [0, width)
|
||||
(define (x-coords frame) (tile (iota (width frame)) (height frame)))
|
||||
|
||||
;; Y coordinate for each pixel [0, height)
|
||||
(define (y-coords frame) (repeat (iota (height frame)) (width frame)))
|
||||
|
||||
;; Normalized X coordinate [0, 1]
|
||||
(define (x-norm frame) (/ (x-coords frame) (max2 1 (- (width frame) 1))))
|
||||
|
||||
;; Normalized Y coordinate [0, 1]
|
||||
(define (y-norm frame) (/ (y-coords frame) (max2 1 (- (height frame) 1))))
|
||||
|
||||
;; Distance from frame center for each pixel
|
||||
(define (dist-from-center frame)
|
||||
(let* ((cx (/ (width frame) 2))
|
||||
(cy (/ (height frame) 2))
|
||||
(dx (- (x-coords frame) cx))
|
||||
(dy (- (y-coords frame) cy)))
|
||||
(sqrt (+ (sq dx) (sq dy)))))
|
||||
|
||||
;; Normalized distance from center [0, ~1]
|
||||
(define (dist-norm frame)
|
||||
(let ((d (dist-from-center frame)))
|
||||
(/ d (max2 1 (βmax d)))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Cell/Grid Operations (derivable from floor + basic math)
|
||||
;; =============================================================================
|
||||
|
||||
;; Cell row index for each pixel
|
||||
(define (cell-row frame cell-size) (floor (/ (y-coords frame) cell-size)))
|
||||
|
||||
;; Cell column index for each pixel
|
||||
(define (cell-col frame cell-size) (floor (/ (x-coords frame) cell-size)))
|
||||
|
||||
;; Number of cell rows
|
||||
(define (num-rows frame cell-size) (floor (/ (height frame) cell-size)))
|
||||
|
||||
;; Number of cell columns
|
||||
(define (num-cols frame cell-size) (floor (/ (width frame) cell-size)))
|
||||
|
||||
;; Flat cell index for each pixel
|
||||
(define (cell-indices frame cell-size)
|
||||
(+ (* (cell-row frame cell-size) (num-cols frame cell-size))
|
||||
(cell-col frame cell-size)))
|
||||
|
||||
;; Total number of cells
|
||||
(define (num-cells frame cell-size)
|
||||
(* (num-rows frame cell-size) (num-cols frame cell-size)))
|
||||
|
||||
;; X position within cell [0, cell-size)
|
||||
(define (local-x frame cell-size) (mod (x-coords frame) cell-size))
|
||||
|
||||
;; Y position within cell [0, cell-size)
|
||||
(define (local-y frame cell-size) (mod (y-coords frame) cell-size))
|
||||
|
||||
;; Normalized X within cell [0, 1]
|
||||
(define (local-x-norm frame cell-size)
|
||||
(/ (local-x frame cell-size) (max2 1 (- cell-size 1))))
|
||||
|
||||
;; Normalized Y within cell [0, 1]
|
||||
(define (local-y-norm frame cell-size)
|
||||
(/ (local-y frame cell-size) (max2 1 (- cell-size 1))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Fill Operations (derivable from iota)
|
||||
;; =============================================================================
|
||||
|
||||
;; Xector of n zeros
|
||||
(define (zeros n) (* (iota n) 0))
|
||||
|
||||
;; Xector of n ones
|
||||
(define (ones n) (+ (zeros n) 1))
|
||||
|
||||
;; Xector of n copies of val
|
||||
(define (fill val n) (+ (zeros n) val))
|
||||
|
||||
;; Xector of zeros matching x's length
|
||||
(define (zeros-like x) (* x 0))
|
||||
|
||||
;; Xector of ones matching x's length
|
||||
(define (ones-like x) (+ (zeros-like x) 1))
|
||||
|
||||
;; =============================================================================
|
||||
;; Pooling (derivable from group-reduce)
|
||||
;; =============================================================================
|
||||
|
||||
;; Pool a channel by cell index
|
||||
(define (pool-channel chan cell-idx num-cells)
|
||||
(group-reduce chan cell-idx num-cells "mean"))
|
||||
|
||||
;; Pool red channel to cells
|
||||
(define (pool-red frame cell-size)
|
||||
(pool-channel (red frame)
|
||||
(cell-indices frame cell-size)
|
||||
(num-cells frame cell-size)))
|
||||
|
||||
;; Pool green channel to cells
|
||||
(define (pool-green frame cell-size)
|
||||
(pool-channel (green frame)
|
||||
(cell-indices frame cell-size)
|
||||
(num-cells frame cell-size)))
|
||||
|
||||
;; Pool blue channel to cells
|
||||
(define (pool-blue frame cell-size)
|
||||
(pool-channel (blue frame)
|
||||
(cell-indices frame cell-size)
|
||||
(num-cells frame cell-size)))
|
||||
|
||||
;; Pool grayscale to cells
|
||||
(define (pool-gray frame cell-size)
|
||||
(pool-channel (gray frame)
|
||||
(cell-indices frame cell-size)
|
||||
(num-cells frame cell-size)))
|
||||
|
||||
;; =============================================================================
|
||||
;; Blending (derivable from math)
|
||||
;; =============================================================================
|
||||
|
||||
;; Additive blend
|
||||
(define (blend-add a b) (clamp (+ a b) 0 255))
|
||||
|
||||
;; Multiply blend (normalized)
|
||||
(define (blend-multiply a b) (* (/ a 255) b))
|
||||
|
||||
;; Screen blend
|
||||
(define (blend-screen a b) (- 255 (* (/ (- 255 a) 255) (- 255 b))))
|
||||
|
||||
;; Overlay blend
|
||||
(define (blend-overlay a b)
|
||||
(where (< a 128)
|
||||
(* 2 (/ (* a b) 255))
|
||||
(- 255 (* 2 (/ (* (- 255 a) (- 255 b)) 255)))))
|
||||
|
||||
;; =============================================================================
|
||||
;; Simple Effects (derivable from primitives)
|
||||
;; =============================================================================
|
||||
|
||||
;; Invert a channel (255 - c)
|
||||
(define (invert-channel c) (- 255 c))
|
||||
|
||||
;; Binary threshold
|
||||
(define (threshold-channel c thresh) (where (> c thresh) 255 0))
|
||||
|
||||
;; Reduce to n levels
|
||||
(define (posterize-channel c levels)
|
||||
(let ((step (/ 255 (- levels 1))))
|
||||
(* (round (/ c step)) step)))
|
||||
17
l1/sexp_effects/effects/ascii_art.sexp
Normal file
17
l1/sexp_effects/effects/ascii_art.sexp
Normal file
@@ -0,0 +1,17 @@
|
||||
;; ASCII Art effect - converts image to ASCII characters
|
||||
(require-primitives "ascii")
|
||||
|
||||
(define-effect ascii_art
|
||||
:params (
|
||||
(char_size :type int :default 8 :range [4 32])
|
||||
(alphabet :type string :default "standard")
|
||||
(color_mode :type string :default "color" :desc "color, mono, invert, or any color name/hex")
|
||||
(background_color :type string :default "black" :desc "background color name/hex")
|
||||
(invert_colors :type int :default 0 :desc "swap foreground and background colors")
|
||||
(contrast :type float :default 1.5 :range [1 3])
|
||||
)
|
||||
(let* ((sample (cell-sample frame char_size))
|
||||
(colors (nth sample 0))
|
||||
(luminances (nth sample 1))
|
||||
(chars (luminance-to-chars luminances alphabet contrast)))
|
||||
(render-char-grid frame chars colors char_size color_mode background_color invert_colors)))
|
||||
52
l1/sexp_effects/effects/ascii_art_fx.sexp
Normal file
52
l1/sexp_effects/effects/ascii_art_fx.sexp
Normal file
@@ -0,0 +1,52 @@
|
||||
;; ASCII Art FX - converts image to ASCII characters with per-character effects
|
||||
(require-primitives "ascii")
|
||||
|
||||
(define-effect ascii_art_fx
|
||||
:params (
|
||||
;; Basic parameters
|
||||
(char_size :type int :default 8 :range [4 32]
|
||||
:desc "Size of each character cell in pixels")
|
||||
(alphabet :type string :default "standard"
|
||||
:desc "Character set to use")
|
||||
(color_mode :type string :default "color"
|
||||
:choices [color mono invert]
|
||||
:desc "Color mode: color, mono, invert, or any color name/hex")
|
||||
(background_color :type string :default "black"
|
||||
:desc "Background color name or hex value")
|
||||
(invert_colors :type int :default 0 :range [0 1]
|
||||
:desc "Swap foreground and background colors (0/1)")
|
||||
(contrast :type float :default 1.5 :range [1 3]
|
||||
:desc "Character selection contrast")
|
||||
|
||||
;; Per-character effects
|
||||
(char_jitter :type float :default 0 :range [0 20]
|
||||
:desc "Position jitter amount in pixels")
|
||||
(char_scale :type float :default 1.0 :range [0.5 2.0]
|
||||
:desc "Character scale factor")
|
||||
(char_rotation :type float :default 0 :range [0 180]
|
||||
:desc "Rotation amount in degrees")
|
||||
(char_hue_shift :type float :default 0 :range [0 360]
|
||||
:desc "Hue shift in degrees")
|
||||
|
||||
;; Modulation sources
|
||||
(jitter_source :type string :default "none"
|
||||
:choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist]
|
||||
:desc "What drives jitter modulation")
|
||||
(scale_source :type string :default "none"
|
||||
:choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist]
|
||||
:desc "What drives scale modulation")
|
||||
(rotation_source :type string :default "none"
|
||||
:choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist]
|
||||
:desc "What drives rotation modulation")
|
||||
(hue_source :type string :default "none"
|
||||
:choices [none luminance inv_luminance saturation position_x position_y position_diag random center_dist]
|
||||
:desc "What drives hue shift modulation")
|
||||
)
|
||||
(let* ((sample (cell-sample frame char_size))
|
||||
(colors (nth sample 0))
|
||||
(luminances (nth sample 1))
|
||||
(chars (luminance-to-chars luminances alphabet contrast)))
|
||||
(render-char-grid-fx frame chars colors luminances char_size
|
||||
color_mode background_color invert_colors
|
||||
char_jitter char_scale char_rotation char_hue_shift
|
||||
jitter_source scale_source rotation_source hue_source)))
|
||||
102
l1/sexp_effects/effects/ascii_fx_zone.sexp
Normal file
102
l1/sexp_effects/effects/ascii_fx_zone.sexp
Normal file
@@ -0,0 +1,102 @@
|
||||
;; Composable ASCII Art with Per-Zone Expression-Driven Effects
|
||||
;; Requires ascii primitive library for the ascii-fx-zone primitive
|
||||
|
||||
(require-primitives "ascii")
|
||||
|
||||
;; Two modes of operation:
|
||||
;;
|
||||
;; 1. EXPRESSION MODE: Use zone-* variables in expression parameters
|
||||
;; Zone variables available:
|
||||
;; zone-row, zone-col: Grid position (integers)
|
||||
;; zone-row-norm, zone-col-norm: Normalized position (0-1)
|
||||
;; zone-lum: Cell luminance (0-1)
|
||||
;; zone-sat: Cell saturation (0-1)
|
||||
;; zone-hue: Cell hue (0-360)
|
||||
;; zone-r, zone-g, zone-b: RGB components (0-1)
|
||||
;;
|
||||
;; Example:
|
||||
;; (ascii-fx-zone frame
|
||||
;; :cols 80
|
||||
;; :char_hue (* zone-lum 180)
|
||||
;; :char_rotation (* zone-col-norm 30))
|
||||
;;
|
||||
;; 2. CELL EFFECT MODE: Pass a lambda to apply arbitrary effects per-cell
|
||||
;; The lambda receives (cell-image zone-dict) and returns modified cell.
|
||||
;; Zone dict contains: row, col, row-norm, col-norm, lum, sat, hue, r, g, b,
|
||||
;; char, color, cell_size, plus any bound analysis values.
|
||||
;;
|
||||
;; Any loaded sexp effect can be called on cells - each cell is just a small frame:
|
||||
;; (blur cell radius) - Gaussian blur
|
||||
;; (rotate cell angle) - Rotate by angle degrees
|
||||
;; (brightness cell factor) - Adjust brightness
|
||||
;; (contrast cell factor) - Adjust contrast
|
||||
;; (saturation cell factor) - Adjust saturation
|
||||
;; (hue_shift cell degrees) - Shift hue
|
||||
;; (rgb_split cell offset_x offset_y) - RGB channel split
|
||||
;; (invert cell) - Invert colors
|
||||
;; (pixelate cell block_size) - Pixelate
|
||||
;; (wave cell amplitude freq) - Wave distortion
|
||||
;; ... and any other loaded effect
|
||||
;;
|
||||
;; Example:
|
||||
;; (ascii-fx-zone frame
|
||||
;; :cols 60
|
||||
;; :cell_effect (lambda [cell zone]
|
||||
;; (blur (rotate cell (* (get zone "energy") 45))
|
||||
;; (if (> (get zone "lum") 0.5) 3 0))))
|
||||
|
||||
(define-effect ascii_fx_zone
|
||||
:params (
|
||||
(cols :type int :default 80 :range [20 200]
|
||||
:desc "Number of character columns")
|
||||
(char_size :type int :default nil :range [4 32]
|
||||
:desc "Character cell size in pixels (overrides cols if set)")
|
||||
(alphabet :type string :default "standard"
|
||||
:desc "Character set: standard, blocks, simple, digits, or custom string")
|
||||
(color_mode :type string :default "color"
|
||||
:desc "Color mode: color, mono, invert, or any color name/hex")
|
||||
(background :type string :default "black"
|
||||
:desc "Background color name or hex value")
|
||||
(contrast :type float :default 1.5 :range [0.5 3.0]
|
||||
:desc "Contrast for character selection")
|
||||
(char_hue :type any :default nil
|
||||
:desc "Hue shift expression (evaluated per-zone with zone-* vars)")
|
||||
(char_saturation :type any :default nil
|
||||
:desc "Saturation multiplier expression (1.0 = unchanged)")
|
||||
(char_brightness :type any :default nil
|
||||
:desc "Brightness multiplier expression (1.0 = unchanged)")
|
||||
(char_scale :type any :default nil
|
||||
:desc "Character scale expression (1.0 = normal size)")
|
||||
(char_rotation :type any :default nil
|
||||
:desc "Character rotation expression (degrees)")
|
||||
(char_jitter :type any :default nil
|
||||
:desc "Position jitter expression (pixels)")
|
||||
(cell_effect :type any :default nil
|
||||
:desc "Lambda (cell zone) -> cell for arbitrary per-cell effects")
|
||||
;; Convenience params for staged recipes (avoids compile-time expression issues)
|
||||
(energy :type float :default nil
|
||||
:desc "Energy multiplier (0-1) from audio analysis bind")
|
||||
(rotation_scale :type float :default 0
|
||||
:desc "Max rotation at top-right when energy=1 (degrees)")
|
||||
)
|
||||
;; The ascii-fx-zone special form handles expression params
|
||||
;; If energy + rotation_scale provided, it builds: energy * scale * position_factor
|
||||
;; where position_factor = 0 at bottom-left, 3 at top-right
|
||||
;; If cell_effect provided, each character is rendered to a cell image,
|
||||
;; passed to the lambda, and the result composited back
|
||||
(ascii-fx-zone frame
|
||||
:cols cols
|
||||
:char_size char_size
|
||||
:alphabet alphabet
|
||||
:color_mode color_mode
|
||||
:background background
|
||||
:contrast contrast
|
||||
:char_hue char_hue
|
||||
:char_saturation char_saturation
|
||||
:char_brightness char_brightness
|
||||
:char_scale char_scale
|
||||
:char_rotation char_rotation
|
||||
:char_jitter char_jitter
|
||||
:cell_effect cell_effect
|
||||
:energy energy
|
||||
:rotation_scale rotation_scale))
|
||||
30
l1/sexp_effects/effects/ascii_zones.sexp
Normal file
30
l1/sexp_effects/effects/ascii_zones.sexp
Normal file
@@ -0,0 +1,30 @@
|
||||
;; ASCII Zones effect - different character sets for different brightness zones
|
||||
;; Dark areas use simple chars, mid uses standard, bright uses blocks
|
||||
(require-primitives "ascii")
|
||||
|
||||
(define-effect ascii_zones
|
||||
:params (
|
||||
(char_size :type int :default 8 :range [4 32])
|
||||
(dark_threshold :type int :default 80 :range [0 128])
|
||||
(bright_threshold :type int :default 180 :range [128 255])
|
||||
(color_mode :type string :default "color")
|
||||
)
|
||||
(let* ((sample (cell-sample frame char_size))
|
||||
(colors (nth sample 0))
|
||||
(luminances (nth sample 1))
|
||||
;; Start with simple chars as base
|
||||
(base-chars (luminance-to-chars luminances "simple" 1.2))
|
||||
;; Map each cell to appropriate alphabet based on brightness zone
|
||||
(zoned-chars (map-char-grid base-chars luminances
|
||||
(lambda (r c ch lum)
|
||||
(cond
|
||||
;; Bright zones: use block characters
|
||||
((> lum bright_threshold)
|
||||
(alphabet-char "blocks" (floor (/ (- lum bright_threshold) 15))))
|
||||
;; Dark zones: use simple sparse chars
|
||||
((< lum dark_threshold)
|
||||
(alphabet-char " .-" (floor (/ lum 30))))
|
||||
;; Mid zones: use standard ASCII
|
||||
(else
|
||||
(alphabet-char "standard" (floor (/ lum 4)))))))))
|
||||
(render-char-grid frame zoned-chars colors char_size color_mode (list 0 0 0))))
|
||||
31
l1/sexp_effects/effects/blend.sexp
Normal file
31
l1/sexp_effects/effects/blend.sexp
Normal file
@@ -0,0 +1,31 @@
|
||||
;; Blend effect - combines two video frames
|
||||
;; Streaming-compatible: frame is background, overlay is second frame
|
||||
;; Usage: (blend background overlay :opacity 0.5 :mode "alpha")
|
||||
;;
|
||||
;; Params:
|
||||
;; mode - blend mode (add, multiply, screen, overlay, difference, lighten, darken, alpha)
|
||||
;; opacity - blend amount (0-1)
|
||||
|
||||
(require-primitives "image" "blending" "core")
|
||||
|
||||
(define-effect blend
|
||||
:params (
|
||||
(overlay :type frame :default nil)
|
||||
(mode :type string :default "alpha")
|
||||
(opacity :type float :default 0.5)
|
||||
)
|
||||
(if (core:is-nil overlay)
|
||||
frame
|
||||
(let [a frame
|
||||
b overlay
|
||||
a-h (image:height a)
|
||||
a-w (image:width a)
|
||||
b-h (image:height b)
|
||||
b-w (image:width b)
|
||||
;; Resize b to match a if needed
|
||||
b-sized (if (and (= a-w b-w) (= a-h b-h))
|
||||
b
|
||||
(image:resize b a-w a-h "linear"))]
|
||||
(if (= mode "alpha")
|
||||
(blending:blend-images a b-sized opacity)
|
||||
(blending:blend-images a (blending:blend-mode a b-sized mode) opacity)))))
|
||||
58
l1/sexp_effects/effects/blend_multi.sexp
Normal file
58
l1/sexp_effects/effects/blend_multi.sexp
Normal file
@@ -0,0 +1,58 @@
|
||||
;; N-way weighted blend effect
|
||||
;; Streaming-compatible: pass inputs as a list of frames
|
||||
;; Usage: (blend_multi :inputs [(read a) (read b) (read c)] :weights [0.3 0.4 0.3])
|
||||
;;
|
||||
;; Parameters:
|
||||
;; inputs - list of N frames to blend
|
||||
;; weights - list of N floats, one per input (resolved per-frame)
|
||||
;; mode - blend mode applied when folding each frame in:
|
||||
;; "alpha" — pure weighted average (default)
|
||||
;; "multiply" — darken by multiplication
|
||||
;; "screen" — lighten (inverse multiply)
|
||||
;; "overlay" — contrast-boosting midtone blend
|
||||
;; "soft-light" — gentle dodge/burn
|
||||
;; "hard-light" — strong dodge/burn
|
||||
;; "color-dodge" — brightens towards white
|
||||
;; "color-burn" — darkens towards black
|
||||
;; "difference" — absolute pixel difference
|
||||
;; "exclusion" — softer difference
|
||||
;; "add" — additive (clamped)
|
||||
;; "subtract" — subtractive (clamped)
|
||||
;; "darken" — per-pixel minimum
|
||||
;; "lighten" — per-pixel maximum
|
||||
;; resize_mode - how to match frame dimensions (fit, crop, stretch)
|
||||
;;
|
||||
;; Uses a left-fold over inputs[1..N-1]. At each step the running
|
||||
;; opacity is: w[i] / (w[0] + w[1] + ... + w[i])
|
||||
;; which produces the correct normalised weighted result.
|
||||
|
||||
(require-primitives "image" "blending")
|
||||
|
||||
(define-effect blend_multi
|
||||
:params (
|
||||
(inputs :type list :default [])
|
||||
(weights :type list :default [])
|
||||
(mode :type string :default "alpha")
|
||||
(resize_mode :type string :default "fit")
|
||||
)
|
||||
(let [n (len inputs)
|
||||
;; Target dimensions from first frame
|
||||
target-w (image:width (nth inputs 0))
|
||||
target-h (image:height (nth inputs 0))
|
||||
;; Fold over indices 1..n-1
|
||||
;; Accumulator is (list blended-frame running-weight-sum)
|
||||
seed (list (nth inputs 0) (nth weights 0))
|
||||
result (reduce (range 1 n) seed
|
||||
(lambda (pair i)
|
||||
(let [acc (nth pair 0)
|
||||
running (nth pair 1)
|
||||
w (nth weights i)
|
||||
new-running (+ running w)
|
||||
opacity (/ w (max new-running 0.001))
|
||||
f (image:resize (nth inputs i) target-w target-h "linear")
|
||||
;; Apply blend mode then mix with opacity
|
||||
blended (if (= mode "alpha")
|
||||
(blending:blend-images acc f opacity)
|
||||
(blending:blend-images acc (blending:blend-mode acc f mode) opacity))]
|
||||
(list blended new-running))))]
|
||||
(nth result 0)))
|
||||
16
l1/sexp_effects/effects/bloom.sexp
Normal file
16
l1/sexp_effects/effects/bloom.sexp
Normal file
@@ -0,0 +1,16 @@
|
||||
;; Bloom effect - glow on bright areas
|
||||
(require-primitives "image" "blending")
|
||||
|
||||
(define-effect bloom
|
||||
:params (
|
||||
(intensity :type float :default 0.5 :range [0 2])
|
||||
(threshold :type int :default 200 :range [0 255])
|
||||
(radius :type int :default 15 :range [1 50])
|
||||
)
|
||||
(let* ((bright (map-pixels frame
|
||||
(lambda (x y c)
|
||||
(if (> (luminance c) threshold)
|
||||
c
|
||||
(rgb 0 0 0)))))
|
||||
(blurred (image:blur bright radius)))
|
||||
(blending:blend-mode frame blurred "add")))
|
||||
8
l1/sexp_effects/effects/blur.sexp
Normal file
8
l1/sexp_effects/effects/blur.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Blur effect - gaussian blur
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect blur
|
||||
:params (
|
||||
(radius :type int :default 5 :range [1 50])
|
||||
)
|
||||
(image:blur frame (max 1 radius)))
|
||||
9
l1/sexp_effects/effects/brightness.sexp
Normal file
9
l1/sexp_effects/effects/brightness.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Brightness effect - adjusts overall brightness
|
||||
;; Uses vectorized adjust primitive for fast processing
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect brightness
|
||||
:params (
|
||||
(amount :type int :default 0 :range [-255 255])
|
||||
)
|
||||
(color_ops:adjust-brightness frame amount))
|
||||
65
l1/sexp_effects/effects/cell_pattern.sexp
Normal file
65
l1/sexp_effects/effects/cell_pattern.sexp
Normal file
@@ -0,0 +1,65 @@
|
||||
;; Cell Pattern effect - custom patterns within cells
|
||||
;;
|
||||
;; Demonstrates building arbitrary per-cell visuals from primitives.
|
||||
;; Uses local coordinates within cells to draw patterns scaled by luminance.
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect cell_pattern
|
||||
:params (
|
||||
(cell-size :type int :default 16 :range [8 48] :desc "Cell size")
|
||||
(pattern :type string :default "diagonal" :desc "Pattern: diagonal, cross, ring")
|
||||
)
|
||||
(let* (
|
||||
;; Pool to get cell colors
|
||||
(pooled (pool-frame frame cell-size))
|
||||
(cell-r (nth pooled 0))
|
||||
(cell-g (nth pooled 1))
|
||||
(cell-b (nth pooled 2))
|
||||
(cell-lum (α/ (nth pooled 3) 255))
|
||||
|
||||
;; Cell indices for each pixel
|
||||
(cell-idx (cell-indices frame cell-size))
|
||||
|
||||
;; Look up cell values for each pixel
|
||||
(pix-r (gather cell-r cell-idx))
|
||||
(pix-g (gather cell-g cell-idx))
|
||||
(pix-b (gather cell-b cell-idx))
|
||||
(pix-lum (gather cell-lum cell-idx))
|
||||
|
||||
;; Local position within cell [0, 1]
|
||||
(lx (local-x-norm frame cell-size))
|
||||
(ly (local-y-norm frame cell-size))
|
||||
|
||||
;; Pattern mask based on pattern type
|
||||
(mask
|
||||
(cond
|
||||
;; Diagonal lines - thickness based on luminance
|
||||
((= pattern "diagonal")
|
||||
(let* ((diag (αmod (α+ lx ly) 0.25))
|
||||
(thickness (α* pix-lum 0.125)))
|
||||
(α< diag thickness)))
|
||||
|
||||
;; Cross pattern
|
||||
((= pattern "cross")
|
||||
(let* ((cx (αabs (α- lx 0.5)))
|
||||
(cy (αabs (α- ly 0.5)))
|
||||
(thickness (α* pix-lum 0.25)))
|
||||
(αor (α< cx thickness) (α< cy thickness))))
|
||||
|
||||
;; Ring pattern
|
||||
((= pattern "ring")
|
||||
(let* ((dx (α- lx 0.5))
|
||||
(dy (α- ly 0.5))
|
||||
(dist (αsqrt (α+ (α² dx) (α² dy))))
|
||||
(target (α* pix-lum 0.4))
|
||||
(thickness 0.05))
|
||||
(α< (αabs (α- dist target)) thickness)))
|
||||
|
||||
;; Default: solid
|
||||
(else (α> pix-lum 0)))))
|
||||
|
||||
;; Apply mask: show cell color where mask is true, black elsewhere
|
||||
(rgb (where mask pix-r 0)
|
||||
(where mask pix-g 0)
|
||||
(where mask pix-b 0))))
|
||||
13
l1/sexp_effects/effects/color-adjust.sexp
Normal file
13
l1/sexp_effects/effects/color-adjust.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; Color adjustment effect - replaces TRANSFORM node
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect color-adjust
|
||||
:params (
|
||||
(brightness :type int :default 0 :range [-255 255] :desc "Brightness adjustment")
|
||||
(contrast :type float :default 1 :range [0 3] :desc "Contrast multiplier")
|
||||
(saturation :type float :default 1 :range [0 2] :desc "Saturation multiplier")
|
||||
)
|
||||
(-> frame
|
||||
(color_ops:adjust-brightness brightness)
|
||||
(color_ops:adjust-contrast contrast)
|
||||
(color_ops:adjust-saturation saturation)))
|
||||
13
l1/sexp_effects/effects/color_cycle.sexp
Normal file
13
l1/sexp_effects/effects/color_cycle.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; Color Cycle effect - animated hue rotation
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect color_cycle
|
||||
:params (
|
||||
(speed :type int :default 1 :range [0 10])
|
||||
)
|
||||
(let ((shift (* t speed 360)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* ((hsv (rgb->hsv c))
|
||||
(new-h (mod (+ (first hsv) shift) 360)))
|
||||
(hsv->rgb (list new-h (nth hsv 1) (nth hsv 2))))))))
|
||||
9
l1/sexp_effects/effects/contrast.sexp
Normal file
9
l1/sexp_effects/effects/contrast.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Contrast effect - adjusts image contrast
|
||||
;; Uses vectorized adjust primitive for fast processing
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect contrast
|
||||
:params (
|
||||
(amount :type int :default 1 :range [0.5 3])
|
||||
)
|
||||
(color_ops:adjust-contrast frame amount))
|
||||
30
l1/sexp_effects/effects/crt.sexp
Normal file
30
l1/sexp_effects/effects/crt.sexp
Normal file
@@ -0,0 +1,30 @@
|
||||
;; CRT effect - old monitor simulation
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect crt
|
||||
:params (
|
||||
(line_spacing :type int :default 2 :range [1 10])
|
||||
(line_opacity :type float :default 0.3 :range [0 1])
|
||||
(vignette_amount :type float :default 0.2)
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (/ w 2))
|
||||
(cy (/ h 2))
|
||||
(max-dist (sqrt (+ (* cx cx) (* cy cy)))))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* (;; Scanline darkening
|
||||
(scanline-factor (if (= 0 (mod y line_spacing))
|
||||
(- 1 line_opacity)
|
||||
1))
|
||||
;; Vignette
|
||||
(dx (- x cx))
|
||||
(dy (- y cy))
|
||||
(dist (sqrt (+ (* dx dx) (* dy dy))))
|
||||
(vignette-factor (- 1 (* (/ dist max-dist) vignette_amount)))
|
||||
;; Combined
|
||||
(factor (* scanline-factor vignette-factor)))
|
||||
(rgb (* (red c) factor)
|
||||
(* (green c) factor)
|
||||
(* (blue c) factor)))))))
|
||||
14
l1/sexp_effects/effects/datamosh.sexp
Normal file
14
l1/sexp_effects/effects/datamosh.sexp
Normal file
@@ -0,0 +1,14 @@
|
||||
;; Datamosh effect - glitch block corruption
|
||||
|
||||
(define-effect datamosh
|
||||
:params (
|
||||
(block_size :type int :default 32 :range [8 128])
|
||||
(corruption :type float :default 0.3 :range [0 1])
|
||||
(max_offset :type int :default 50 :range [0 200])
|
||||
(color_corrupt :type bool :default true)
|
||||
)
|
||||
;; Get previous frame from state, or use current frame if none
|
||||
(let ((prev (state-get "prev_frame" frame)))
|
||||
(begin
|
||||
(state-set "prev_frame" (copy frame))
|
||||
(datamosh frame prev block_size corruption max_offset color_corrupt))))
|
||||
19
l1/sexp_effects/effects/echo.sexp
Normal file
19
l1/sexp_effects/effects/echo.sexp
Normal file
@@ -0,0 +1,19 @@
|
||||
;; Echo effect - motion trails using frame buffer
|
||||
(require-primitives "blending")
|
||||
|
||||
(define-effect echo
|
||||
:params (
|
||||
(num_echoes :type int :default 4 :range [1 20])
|
||||
(decay :type float :default 0.5 :range [0 1])
|
||||
)
|
||||
(let* ((buffer (state-get "buffer" (list)))
|
||||
(new-buffer (take (cons frame buffer) (+ num_echoes 1))))
|
||||
(begin
|
||||
(state-set "buffer" new-buffer)
|
||||
;; Blend frames with decay
|
||||
(if (< (length new-buffer) 2)
|
||||
frame
|
||||
(let ((result (copy frame)))
|
||||
;; Simple blend of first two frames for now
|
||||
;; Full version would fold over all frames
|
||||
(blending:blend-images frame (nth new-buffer 1) (* decay 0.5)))))))
|
||||
9
l1/sexp_effects/effects/edge_detect.sexp
Normal file
9
l1/sexp_effects/effects/edge_detect.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Edge detection effect - highlights edges
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect edge_detect
|
||||
:params (
|
||||
(low :type int :default 50 :range [10 100])
|
||||
(high :type int :default 150 :range [50 300])
|
||||
)
|
||||
(image:edge-detect frame low high))
|
||||
13
l1/sexp_effects/effects/emboss.sexp
Normal file
13
l1/sexp_effects/effects/emboss.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; Emboss effect - creates raised/3D appearance
|
||||
(require-primitives "blending")
|
||||
|
||||
(define-effect emboss
|
||||
:params (
|
||||
(strength :type int :default 1 :range [0.5 3])
|
||||
(blend :type float :default 0.3 :range [0 1])
|
||||
)
|
||||
(let* ((kernel (list (list (- strength) (- strength) 0)
|
||||
(list (- strength) 1 strength)
|
||||
(list 0 strength strength)))
|
||||
(embossed (convolve frame kernel)))
|
||||
(blending:blend-images embossed frame blend)))
|
||||
19
l1/sexp_effects/effects/film_grain.sexp
Normal file
19
l1/sexp_effects/effects/film_grain.sexp
Normal file
@@ -0,0 +1,19 @@
|
||||
;; Film Grain effect - adds film grain texture
|
||||
(require-primitives "core")
|
||||
|
||||
(define-effect film_grain
|
||||
:params (
|
||||
(intensity :type float :default 0.2 :range [0 1])
|
||||
(colored :type bool :default false)
|
||||
)
|
||||
(let ((grain-amount (* intensity 50)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(if colored
|
||||
(rgb (clamp (+ (red c) (gaussian 0 grain-amount)) 0 255)
|
||||
(clamp (+ (green c) (gaussian 0 grain-amount)) 0 255)
|
||||
(clamp (+ (blue c) (gaussian 0 grain-amount)) 0 255))
|
||||
(let ((n (gaussian 0 grain-amount)))
|
||||
(rgb (clamp (+ (red c) n) 0 255)
|
||||
(clamp (+ (green c) n) 0 255)
|
||||
(clamp (+ (blue c) n) 0 255))))))))
|
||||
16
l1/sexp_effects/effects/fisheye.sexp
Normal file
16
l1/sexp_effects/effects/fisheye.sexp
Normal file
@@ -0,0 +1,16 @@
|
||||
;; Fisheye effect - barrel/pincushion lens distortion
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect fisheye
|
||||
:params (
|
||||
(strength :type float :default 0.3 :range [-1 1])
|
||||
(center_x :type float :default 0.5 :range [0 1])
|
||||
(center_y :type float :default 0.5 :range [0 1])
|
||||
(zoom_correct :type bool :default true)
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(coords (geometry:fisheye-coords w h strength cx cy zoom_correct)))
|
||||
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))
|
||||
16
l1/sexp_effects/effects/flip.sexp
Normal file
16
l1/sexp_effects/effects/flip.sexp
Normal file
@@ -0,0 +1,16 @@
|
||||
;; Flip effect - flips image horizontally or vertically
|
||||
(require-primitives "geometry")
|
||||
|
||||
(define-effect flip
|
||||
:params (
|
||||
(horizontal :type bool :default true)
|
||||
(vertical :type bool :default false)
|
||||
)
|
||||
(let ((result frame))
|
||||
(if horizontal
|
||||
(set! result (geometry:flip-img result "horizontal"))
|
||||
nil)
|
||||
(if vertical
|
||||
(set! result (geometry:flip-img result "vertical"))
|
||||
nil)
|
||||
result))
|
||||
7
l1/sexp_effects/effects/grayscale.sexp
Normal file
7
l1/sexp_effects/effects/grayscale.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; Grayscale effect - converts to grayscale
|
||||
;; Uses vectorized mix-gray primitive for fast processing
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect grayscale
|
||||
:params ()
|
||||
(image:grayscale frame))
|
||||
49
l1/sexp_effects/effects/halftone.sexp
Normal file
49
l1/sexp_effects/effects/halftone.sexp
Normal file
@@ -0,0 +1,49 @@
|
||||
;; Halftone/dot effect - built from primitive xector operations
|
||||
;;
|
||||
;; Uses:
|
||||
;; pool-frame - downsample to cell luminances
|
||||
;; cell-indices - which cell each pixel belongs to
|
||||
;; gather - look up cell value for each pixel
|
||||
;; local-x/y-norm - position within cell [0,1]
|
||||
;; where - conditional per-pixel
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect halftone
|
||||
:params (
|
||||
(cell-size :type int :default 12 :range [4 32] :desc "Size of halftone cells")
|
||||
(dot-scale :type float :default 0.9 :range [0.1 1.0] :desc "Max dot radius")
|
||||
(invert :type bool :default false :desc "Invert (white dots on black)")
|
||||
)
|
||||
(let* (
|
||||
;; Pool frame to get luminance per cell
|
||||
(pooled (pool-frame frame cell-size))
|
||||
(cell-lum (nth pooled 3)) ; luminance is 4th element
|
||||
|
||||
;; For each output pixel, get its cell index
|
||||
(cell-idx (cell-indices frame cell-size))
|
||||
|
||||
;; Get cell luminance for each pixel
|
||||
(pixel-lum (α/ (gather cell-lum cell-idx) 255))
|
||||
|
||||
;; Position within cell, normalized to [-0.5, 0.5]
|
||||
(lx (α- (local-x-norm frame cell-size) 0.5))
|
||||
(ly (α- (local-y-norm frame cell-size) 0.5))
|
||||
|
||||
;; Distance from cell center (0 at center, ~0.7 at corners)
|
||||
(dist (αsqrt (α+ (α² lx) (α² ly))))
|
||||
|
||||
;; Radius based on luminance (brighter = bigger dot)
|
||||
(radius (α* (if invert (α- 1 pixel-lum) pixel-lum)
|
||||
(α* dot-scale 0.5)))
|
||||
|
||||
;; Is this pixel inside the dot?
|
||||
(inside (α< dist radius))
|
||||
|
||||
;; Output color
|
||||
(fg (if invert 255 0))
|
||||
(bg (if invert 0 255))
|
||||
(out (where inside fg bg)))
|
||||
|
||||
;; Grayscale output
|
||||
(rgb out out out)))
|
||||
12
l1/sexp_effects/effects/hue_shift.sexp
Normal file
12
l1/sexp_effects/effects/hue_shift.sexp
Normal file
@@ -0,0 +1,12 @@
|
||||
;; Hue shift effect - rotates hue values
|
||||
;; Uses vectorized shift-hsv primitive for fast processing
|
||||
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect hue_shift
|
||||
:params (
|
||||
(degrees :type int :default 0 :range [0 360])
|
||||
(speed :type int :default 0 :desc "rotation per second")
|
||||
)
|
||||
(let ((shift (+ degrees (* speed t))))
|
||||
(color_ops:shift-hsv frame shift 1 1)))
|
||||
9
l1/sexp_effects/effects/invert.sexp
Normal file
9
l1/sexp_effects/effects/invert.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Invert effect - inverts all colors
|
||||
;; Uses vectorized invert-img primitive for fast processing
|
||||
;; amount param: 0 = no invert, 1 = full invert (threshold at 0.5)
|
||||
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect invert
|
||||
:params ((amount :type float :default 1 :range [0 1]))
|
||||
(if (> amount 0.5) (color_ops:invert-img frame) frame))
|
||||
20
l1/sexp_effects/effects/kaleidoscope.sexp
Normal file
20
l1/sexp_effects/effects/kaleidoscope.sexp
Normal file
@@ -0,0 +1,20 @@
|
||||
;; Kaleidoscope effect - mandala-like symmetry patterns
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect kaleidoscope
|
||||
:params (
|
||||
(segments :type int :default 6 :range [3 16])
|
||||
(rotation :type int :default 0 :range [0 360])
|
||||
(rotation_speed :type int :default 0 :range [-180 180])
|
||||
(center_x :type float :default 0.5 :range [0 1])
|
||||
(center_y :type float :default 0.5 :range [0 1])
|
||||
(zoom :type int :default 1 :range [0.5 3])
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
;; Total rotation including time-based animation
|
||||
(total_rot (+ rotation (* rotation_speed (or _time 0))))
|
||||
(coords (geometry:kaleidoscope-coords w h segments total_rot cx cy zoom)))
|
||||
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))
|
||||
36
l1/sexp_effects/effects/layer.sexp
Normal file
36
l1/sexp_effects/effects/layer.sexp
Normal file
@@ -0,0 +1,36 @@
|
||||
;; Layer effect - composite overlay over background at position
|
||||
;; Streaming-compatible: frame is background, overlay is foreground
|
||||
;; Usage: (layer background overlay :x 10 :y 20 :opacity 0.8)
|
||||
;;
|
||||
;; Params:
|
||||
;; overlay - frame to composite on top
|
||||
;; x, y - position to place overlay
|
||||
;; opacity - blend amount (0-1)
|
||||
;; mode - blend mode (alpha, multiply, screen, etc.)
|
||||
|
||||
(require-primitives "image" "blending" "core")
|
||||
|
||||
(define-effect layer
|
||||
:params (
|
||||
(overlay :type frame :default nil)
|
||||
(x :type int :default 0)
|
||||
(y :type int :default 0)
|
||||
(opacity :type float :default 1.0)
|
||||
(mode :type string :default "alpha")
|
||||
)
|
||||
(if (core:is-nil overlay)
|
||||
frame
|
||||
(let [bg (copy frame)
|
||||
fg overlay
|
||||
fg-w (image:width fg)
|
||||
fg-h (image:height fg)]
|
||||
(if (= opacity 1.0)
|
||||
;; Simple paste
|
||||
(paste bg fg x y)
|
||||
;; Blend with opacity
|
||||
(let [blended (if (= mode "alpha")
|
||||
(blending:blend-images (image:crop bg x y fg-w fg-h) fg opacity)
|
||||
(blending:blend-images (image:crop bg x y fg-w fg-h)
|
||||
(blending:blend-mode (image:crop bg x y fg-w fg-h) fg mode)
|
||||
opacity))]
|
||||
(paste bg blended x y))))))
|
||||
33
l1/sexp_effects/effects/mirror.sexp
Normal file
33
l1/sexp_effects/effects/mirror.sexp
Normal file
@@ -0,0 +1,33 @@
|
||||
;; Mirror effect - mirrors half of image
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect mirror
|
||||
:params (
|
||||
(mode :type string :default "left_right")
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(hw (floor (/ w 2)))
|
||||
(hh (floor (/ h 2))))
|
||||
(cond
|
||||
((= mode "left_right")
|
||||
(let ((left (image:crop frame 0 0 hw h))
|
||||
(result (copy frame)))
|
||||
(paste result (geometry:flip-img left "horizontal") hw 0)))
|
||||
|
||||
((= mode "right_left")
|
||||
(let ((right (image:crop frame hw 0 hw h))
|
||||
(result (copy frame)))
|
||||
(paste result (geometry:flip-img right "horizontal") 0 0)))
|
||||
|
||||
((= mode "top_bottom")
|
||||
(let ((top (image:crop frame 0 0 w hh))
|
||||
(result (copy frame)))
|
||||
(paste result (geometry:flip-img top "vertical") 0 hh)))
|
||||
|
||||
((= mode "bottom_top")
|
||||
(let ((bottom (image:crop frame 0 hh w hh))
|
||||
(result (copy frame)))
|
||||
(paste result (geometry:flip-img bottom "vertical") 0 0)))
|
||||
|
||||
(else frame))))
|
||||
30
l1/sexp_effects/effects/mosaic.sexp
Normal file
30
l1/sexp_effects/effects/mosaic.sexp
Normal file
@@ -0,0 +1,30 @@
|
||||
;; Mosaic effect - built from primitive xector operations
|
||||
;;
|
||||
;; Uses:
|
||||
;; pool-frame - downsample to cell averages
|
||||
;; cell-indices - which cell each pixel belongs to
|
||||
;; gather - look up cell value for each pixel
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect mosaic
|
||||
:params (
|
||||
(cell-size :type int :default 16 :range [4 64] :desc "Size of mosaic cells")
|
||||
)
|
||||
(let* (
|
||||
;; Pool frame to get average color per cell (returns r,g,b,lum xectors)
|
||||
(pooled (pool-frame frame cell-size))
|
||||
(cell-r (nth pooled 0))
|
||||
(cell-g (nth pooled 1))
|
||||
(cell-b (nth pooled 2))
|
||||
|
||||
;; For each output pixel, get its cell index
|
||||
(cell-idx (cell-indices frame cell-size))
|
||||
|
||||
;; Gather: look up cell color for each pixel
|
||||
(out-r (gather cell-r cell-idx))
|
||||
(out-g (gather cell-g cell-idx))
|
||||
(out-b (gather cell-b cell-idx)))
|
||||
|
||||
;; Reconstruct frame
|
||||
(rgb out-r out-g out-b)))
|
||||
23
l1/sexp_effects/effects/neon_glow.sexp
Normal file
23
l1/sexp_effects/effects/neon_glow.sexp
Normal file
@@ -0,0 +1,23 @@
|
||||
;; Neon Glow effect - glowing edge effect
|
||||
(require-primitives "image" "blending")
|
||||
|
||||
(define-effect neon_glow
|
||||
:params (
|
||||
(edge_low :type int :default 50 :range [10 200])
|
||||
(edge_high :type int :default 150 :range [50 300])
|
||||
(glow_radius :type int :default 15 :range [1 50])
|
||||
(glow_intensity :type int :default 2 :range [0.5 5])
|
||||
(background :type float :default 0.3 :range [0 1])
|
||||
)
|
||||
(let* ((edge-img (image:edge-detect frame edge_low edge_high))
|
||||
(glow (image:blur edge-img glow_radius))
|
||||
;; Intensify the glow
|
||||
(bright-glow (map-pixels glow
|
||||
(lambda (x y c)
|
||||
(rgb (clamp (* (red c) glow_intensity) 0 255)
|
||||
(clamp (* (green c) glow_intensity) 0 255)
|
||||
(clamp (* (blue c) glow_intensity) 0 255))))))
|
||||
(blending:blend-mode (blending:blend-images frame (make-image (image:width frame) (image:height frame) (list 0 0 0))
|
||||
(- 1 background))
|
||||
bright-glow
|
||||
"screen")))
|
||||
8
l1/sexp_effects/effects/noise.sexp
Normal file
8
l1/sexp_effects/effects/noise.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Noise effect - adds random noise
|
||||
;; Uses vectorized add-noise primitive for fast processing
|
||||
|
||||
(define-effect noise
|
||||
:params (
|
||||
(amount :type int :default 20 :range [0 100])
|
||||
)
|
||||
(add-noise frame amount))
|
||||
24
l1/sexp_effects/effects/outline.sexp
Normal file
24
l1/sexp_effects/effects/outline.sexp
Normal file
@@ -0,0 +1,24 @@
|
||||
;; Outline effect - shows only edges
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect outline
|
||||
:params (
|
||||
(thickness :type int :default 2 :range [1 10])
|
||||
(threshold :type int :default 100 :range [20 300])
|
||||
(color :type list :default (list 0 0 0))
|
||||
(fill_mode :type string :default "original")
|
||||
)
|
||||
(let* ((edge-img (image:edge-detect frame (/ threshold 2) threshold))
|
||||
(dilated (if (> thickness 1)
|
||||
(dilate edge-img thickness)
|
||||
edge-img))
|
||||
(base (cond
|
||||
((= fill_mode "original") (copy frame))
|
||||
((= fill_mode "white") (make-image (image:width frame) (image:height frame) (list 255 255 255)))
|
||||
(else (make-image (image:width frame) (image:height frame) (list 0 0 0))))))
|
||||
(map-pixels base
|
||||
(lambda (x y c)
|
||||
(let ((edge-val (luminance (pixel dilated x y))))
|
||||
(if (> edge-val 128)
|
||||
color
|
||||
c))))))
|
||||
13
l1/sexp_effects/effects/pixelate.sexp
Normal file
13
l1/sexp_effects/effects/pixelate.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; Pixelate effect - creates blocky pixels
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect pixelate
|
||||
:params (
|
||||
(block_size :type int :default 8 :range [2 64])
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(small-w (max 1 (floor (/ w block_size))))
|
||||
(small-h (max 1 (floor (/ h block_size))))
|
||||
(small (image:resize frame small-w small-h "area")))
|
||||
(image:resize small w h "nearest")))
|
||||
11
l1/sexp_effects/effects/pixelsort.sexp
Normal file
11
l1/sexp_effects/effects/pixelsort.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Pixelsort effect - glitch art pixel sorting
|
||||
|
||||
(define-effect pixelsort
|
||||
:params (
|
||||
(sort_by :type string :default "lightness")
|
||||
(threshold_low :type int :default 50 :range [0 255])
|
||||
(threshold_high :type int :default 200 :range [0 255])
|
||||
(angle :type int :default 0 :range [0 180])
|
||||
(reverse :type bool :default false)
|
||||
)
|
||||
(pixelsort frame sort_by threshold_low threshold_high angle reverse))
|
||||
8
l1/sexp_effects/effects/posterize.sexp
Normal file
8
l1/sexp_effects/effects/posterize.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Posterize effect - reduces color levels
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect posterize
|
||||
:params (
|
||||
(levels :type int :default 8 :range [2 32])
|
||||
)
|
||||
(color_ops:posterize frame levels))
|
||||
11
l1/sexp_effects/effects/resize-frame.sexp
Normal file
11
l1/sexp_effects/effects/resize-frame.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Resize effect - replaces RESIZE node
|
||||
;; Note: uses target-w/target-h to avoid conflict with width/height primitives
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect resize-frame
|
||||
:params (
|
||||
(target-w :type int :default 640 :desc "Target width in pixels")
|
||||
(target-h :type int :default 480 :desc "Target height in pixels")
|
||||
(mode :type string :default "linear" :choices [linear nearest area] :desc "Interpolation mode")
|
||||
)
|
||||
(image:resize frame target-w target-h mode))
|
||||
13
l1/sexp_effects/effects/rgb_split.sexp
Normal file
13
l1/sexp_effects/effects/rgb_split.sexp
Normal file
@@ -0,0 +1,13 @@
|
||||
;; RGB Split effect - chromatic aberration
|
||||
|
||||
(define-effect rgb_split
|
||||
:params (
|
||||
(offset_x :type int :default 10 :range [-50 50])
|
||||
(offset_y :type int :default 0 :range [-50 50])
|
||||
)
|
||||
(let* ((r (channel frame 0))
|
||||
(g (channel frame 1))
|
||||
(b (channel frame 2))
|
||||
(r-shifted (translate (merge-channels r r r) offset_x offset_y))
|
||||
(b-shifted (translate (merge-channels b b b) (- offset_x) (- offset_y))))
|
||||
(merge-channels (channel r-shifted 0) g (channel b-shifted 0))))
|
||||
19
l1/sexp_effects/effects/ripple.sexp
Normal file
19
l1/sexp_effects/effects/ripple.sexp
Normal file
@@ -0,0 +1,19 @@
|
||||
;; Ripple effect - radial wave distortion from center
|
||||
(require-primitives "geometry" "image" "math")
|
||||
|
||||
(define-effect ripple
|
||||
:params (
|
||||
(frequency :type int :default 5 :range [1 20])
|
||||
(amplitude :type int :default 10 :range [0 50])
|
||||
(center_x :type float :default 0.5 :range [0 1])
|
||||
(center_y :type float :default 0.5 :range [0 1])
|
||||
(decay :type int :default 1 :range [0 5])
|
||||
(speed :type int :default 1 :range [0 10])
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(phase (* (or t 0) speed 2 pi))
|
||||
(coords (geometry:ripple-displace w h frequency amplitude cx cy decay phase)))
|
||||
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))
|
||||
11
l1/sexp_effects/effects/rotate.sexp
Normal file
11
l1/sexp_effects/effects/rotate.sexp
Normal file
@@ -0,0 +1,11 @@
|
||||
;; Rotate effect - rotates image
|
||||
|
||||
(require-primitives "geometry")
|
||||
|
||||
(define-effect rotate
|
||||
:params (
|
||||
(angle :type int :default 0 :range [-360 360])
|
||||
(speed :type int :default 0 :desc "rotation per second")
|
||||
)
|
||||
(let ((total-angle (+ angle (* speed t))))
|
||||
(geometry:rotate-img frame total-angle)))
|
||||
9
l1/sexp_effects/effects/saturation.sexp
Normal file
9
l1/sexp_effects/effects/saturation.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Saturation effect - adjusts color saturation
|
||||
;; Uses vectorized shift-hsv primitive for fast processing
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect saturation
|
||||
:params (
|
||||
(amount :type int :default 1 :range [0 3])
|
||||
)
|
||||
(color_ops:adjust-saturation frame amount))
|
||||
15
l1/sexp_effects/effects/scanlines.sexp
Normal file
15
l1/sexp_effects/effects/scanlines.sexp
Normal file
@@ -0,0 +1,15 @@
|
||||
;; Scanlines effect - VHS-style horizontal line shifting
|
||||
(require-primitives "core")
|
||||
|
||||
(define-effect scanlines
|
||||
:params (
|
||||
(amplitude :type int :default 10 :range [0 100])
|
||||
(frequency :type int :default 10 :range [1 100])
|
||||
(randomness :type float :default 0.5 :range [0 1])
|
||||
)
|
||||
(map-rows frame
|
||||
(lambda (y row)
|
||||
(let* ((sine-shift (* amplitude (sin (/ (* y 6.28) (max 1 frequency)))))
|
||||
(rand-shift (core:rand-range (- amplitude) amplitude))
|
||||
(shift (floor (lerp sine-shift rand-shift randomness))))
|
||||
(roll row shift 0)))))
|
||||
7
l1/sexp_effects/effects/sepia.sexp
Normal file
7
l1/sexp_effects/effects/sepia.sexp
Normal file
@@ -0,0 +1,7 @@
|
||||
;; Sepia effect - applies sepia tone
|
||||
;; Classic warm vintage look
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect sepia
|
||||
:params ()
|
||||
(color_ops:sepia frame))
|
||||
8
l1/sexp_effects/effects/sharpen.sexp
Normal file
8
l1/sexp_effects/effects/sharpen.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Sharpen effect - sharpens edges
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect sharpen
|
||||
:params (
|
||||
(amount :type int :default 1 :range [0 5])
|
||||
)
|
||||
(image:sharpen frame amount))
|
||||
16
l1/sexp_effects/effects/strobe.sexp
Normal file
16
l1/sexp_effects/effects/strobe.sexp
Normal file
@@ -0,0 +1,16 @@
|
||||
;; Strobe effect - holds frames for choppy look
|
||||
(require-primitives "core")
|
||||
|
||||
(define-effect strobe
|
||||
:params (
|
||||
(frame_rate :type int :default 12 :range [1 60])
|
||||
)
|
||||
(let* ((held (state-get "held" nil))
|
||||
(held-until (state-get "held-until" 0))
|
||||
(frame-duration (/ 1 frame_rate)))
|
||||
(if (or (core:is-nil held) (>= t held-until))
|
||||
(begin
|
||||
(state-set "held" (copy frame))
|
||||
(state-set "held-until" (+ t frame-duration))
|
||||
frame)
|
||||
held)))
|
||||
17
l1/sexp_effects/effects/swirl.sexp
Normal file
17
l1/sexp_effects/effects/swirl.sexp
Normal file
@@ -0,0 +1,17 @@
|
||||
;; Swirl effect - spiral vortex distortion
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect swirl
|
||||
:params (
|
||||
(strength :type int :default 1 :range [-10 10])
|
||||
(radius :type float :default 0.5 :range [0.1 2])
|
||||
(center_x :type float :default 0.5 :range [0 1])
|
||||
(center_y :type float :default 0.5 :range [0 1])
|
||||
(falloff :type string :default "quadratic")
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (* w center_x))
|
||||
(cy (* h center_y))
|
||||
(coords (geometry:swirl-coords w h strength radius cx cy falloff)))
|
||||
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))
|
||||
9
l1/sexp_effects/effects/threshold.sexp
Normal file
9
l1/sexp_effects/effects/threshold.sexp
Normal file
@@ -0,0 +1,9 @@
|
||||
;; Threshold effect - converts to black and white
|
||||
(require-primitives "color_ops")
|
||||
|
||||
(define-effect threshold
|
||||
:params (
|
||||
(level :type int :default 128 :range [0 255])
|
||||
(invert :type bool :default false)
|
||||
)
|
||||
(color_ops:threshold frame level invert))
|
||||
29
l1/sexp_effects/effects/tile_grid.sexp
Normal file
29
l1/sexp_effects/effects/tile_grid.sexp
Normal file
@@ -0,0 +1,29 @@
|
||||
;; Tile Grid effect - tiles image in grid
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect tile_grid
|
||||
:params (
|
||||
(rows :type int :default 2 :range [1 10])
|
||||
(cols :type int :default 2 :range [1 10])
|
||||
(gap :type int :default 0 :range [0 50])
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(tile-w (floor (/ (- w (* gap (- cols 1))) cols)))
|
||||
(tile-h (floor (/ (- h (* gap (- rows 1))) rows)))
|
||||
(tile (image:resize frame tile-w tile-h "area"))
|
||||
(result (make-image w h (list 0 0 0))))
|
||||
(begin
|
||||
;; Manually place tiles using nested iteration
|
||||
;; This is a simplified version - full version would loop
|
||||
(paste result tile 0 0)
|
||||
(if (> cols 1)
|
||||
(paste result tile (+ tile-w gap) 0)
|
||||
nil)
|
||||
(if (> rows 1)
|
||||
(paste result tile 0 (+ tile-h gap))
|
||||
nil)
|
||||
(if (and (> cols 1) (> rows 1))
|
||||
(paste result tile (+ tile-w gap) (+ tile-h gap))
|
||||
nil)
|
||||
result)))
|
||||
20
l1/sexp_effects/effects/trails.sexp
Normal file
20
l1/sexp_effects/effects/trails.sexp
Normal file
@@ -0,0 +1,20 @@
|
||||
;; Trails effect - persistent motion trails
|
||||
(require-primitives "image" "blending")
|
||||
|
||||
(define-effect trails
|
||||
:params (
|
||||
(persistence :type float :default 0.8 :range [0 0.99])
|
||||
)
|
||||
(let* ((buffer (state-get "buffer" nil))
|
||||
(current frame))
|
||||
(if (= buffer nil)
|
||||
(begin
|
||||
(state-set "buffer" (copy frame))
|
||||
frame)
|
||||
(let* ((faded (blending:blend-images buffer
|
||||
(make-image (image:width frame) (image:height frame) (list 0 0 0))
|
||||
(- 1 persistence)))
|
||||
(result (blending:blend-mode faded current "lighten")))
|
||||
(begin
|
||||
(state-set "buffer" result)
|
||||
result)))))
|
||||
23
l1/sexp_effects/effects/vignette.sexp
Normal file
23
l1/sexp_effects/effects/vignette.sexp
Normal file
@@ -0,0 +1,23 @@
|
||||
;; Vignette effect - darkens corners
|
||||
(require-primitives "image")
|
||||
|
||||
(define-effect vignette
|
||||
:params (
|
||||
(strength :type float :default 0.5 :range [0 1])
|
||||
(radius :type int :default 1 :range [0.5 2])
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
(cx (/ w 2))
|
||||
(cy (/ h 2))
|
||||
(max-dist (* (sqrt (+ (* cx cx) (* cy cy))) radius)))
|
||||
(map-pixels frame
|
||||
(lambda (x y c)
|
||||
(let* ((dx (- x cx))
|
||||
(dy (- y cy))
|
||||
(dist (sqrt (+ (* dx dx) (* dy dy))))
|
||||
(factor (- 1 (* (/ dist max-dist) strength)))
|
||||
(factor (clamp factor 0 1)))
|
||||
(rgb (* (red c) factor)
|
||||
(* (green c) factor)
|
||||
(* (blue c) factor)))))))
|
||||
22
l1/sexp_effects/effects/wave.sexp
Normal file
22
l1/sexp_effects/effects/wave.sexp
Normal file
@@ -0,0 +1,22 @@
|
||||
;; Wave effect - sine wave displacement distortion
|
||||
(require-primitives "geometry" "image")
|
||||
|
||||
(define-effect wave
|
||||
:params (
|
||||
(amplitude :type int :default 10 :range [0 100])
|
||||
(wavelength :type int :default 50 :range [10 500])
|
||||
(speed :type int :default 1 :range [0 10])
|
||||
(direction :type string :default "horizontal")
|
||||
)
|
||||
(let* ((w (image:width frame))
|
||||
(h (image:height frame))
|
||||
;; Use _time for animation phase
|
||||
(phase (* (or _time 0) speed 2 pi))
|
||||
;; Calculate frequency: waves per dimension
|
||||
(freq (/ (if (= direction "vertical") w h) wavelength))
|
||||
(axis (cond
|
||||
((= direction "horizontal") "x")
|
||||
((= direction "vertical") "y")
|
||||
(else "both")))
|
||||
(coords (geometry:wave-coords w h axis freq amplitude phase)))
|
||||
(geometry:remap frame (geometry:coords-x coords) (geometry:coords-y coords))))
|
||||
44
l1/sexp_effects/effects/xector_feathered_blend.sexp
Normal file
44
l1/sexp_effects/effects/xector_feathered_blend.sexp
Normal file
@@ -0,0 +1,44 @@
|
||||
;; Feathered blend - blend two same-size frames with distance-based falloff
|
||||
;; Center shows overlay, edges show background, with smooth transition
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect xector_feathered_blend
|
||||
:params (
|
||||
(inner-radius :type float :default 0.3 :range [0 1] :desc "Radius where overlay is 100% (fraction of size)")
|
||||
(fade-width :type float :default 0.2 :range [0 0.5] :desc "Width of fade region (fraction of size)")
|
||||
(overlay :type frame :default nil :desc "Frame to blend in center")
|
||||
)
|
||||
(let* (
|
||||
;; Get normalized distance from center (0 at center, ~1 at corners)
|
||||
(dist (dist-from-center frame))
|
||||
(max-dist (βmax dist))
|
||||
(dist-norm (α/ dist max-dist))
|
||||
|
||||
;; Calculate blend factor:
|
||||
;; - 1.0 when dist-norm < inner-radius (fully overlay)
|
||||
;; - 0.0 when dist-norm > inner-radius + fade-width (fully background)
|
||||
;; - linear ramp between
|
||||
(t (α/ (α- dist-norm inner-radius) fade-width))
|
||||
(blend (α- 1 (αclamp t 0 1)))
|
||||
(inv-blend (α- 1 blend))
|
||||
|
||||
;; Background channels
|
||||
(bg-r (red frame))
|
||||
(bg-g (green frame))
|
||||
(bg-b (blue frame)))
|
||||
|
||||
(if (nil? overlay)
|
||||
;; No overlay - visualize the blend mask
|
||||
(let ((vis (α* blend 255)))
|
||||
(rgb vis vis vis))
|
||||
|
||||
;; Blend overlay with background using the mask
|
||||
(let* ((ov-r (red overlay))
|
||||
(ov-g (green overlay))
|
||||
(ov-b (blue overlay))
|
||||
;; lerp: bg * (1-blend) + overlay * blend
|
||||
(r-out (α+ (α* bg-r inv-blend) (α* ov-r blend)))
|
||||
(g-out (α+ (α* bg-g inv-blend) (α* ov-g blend)))
|
||||
(b-out (α+ (α* bg-b inv-blend) (α* ov-b blend))))
|
||||
(rgb r-out g-out b-out)))))
|
||||
34
l1/sexp_effects/effects/xector_grain.sexp
Normal file
34
l1/sexp_effects/effects/xector_grain.sexp
Normal file
@@ -0,0 +1,34 @@
|
||||
;; Film grain effect using xector operations
|
||||
;; Demonstrates random xectors and mixing scalar/xector math
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect xector_grain
|
||||
:params (
|
||||
(intensity :type float :default 0.2 :range [0 1] :desc "Grain intensity")
|
||||
(colored :type bool :default false :desc "Use colored grain")
|
||||
)
|
||||
(let* (
|
||||
;; Extract channels
|
||||
(r (red frame))
|
||||
(g (green frame))
|
||||
(b (blue frame))
|
||||
|
||||
;; Generate noise xector(s)
|
||||
;; randn-x generates normal distribution noise
|
||||
(grain-amount (* intensity 50)))
|
||||
|
||||
(if colored
|
||||
;; Colored grain: different noise per channel
|
||||
(let* ((nr (randn-x frame 0 grain-amount))
|
||||
(ng (randn-x frame 0 grain-amount))
|
||||
(nb (randn-x frame 0 grain-amount)))
|
||||
(rgb (αclamp (α+ r nr) 0 255)
|
||||
(αclamp (α+ g ng) 0 255)
|
||||
(αclamp (α+ b nb) 0 255)))
|
||||
|
||||
;; Monochrome grain: same noise for all channels
|
||||
(let ((n (randn-x frame 0 grain-amount)))
|
||||
(rgb (αclamp (α+ r n) 0 255)
|
||||
(αclamp (α+ g n) 0 255)
|
||||
(αclamp (α+ b n) 0 255))))))
|
||||
57
l1/sexp_effects/effects/xector_inset_blend.sexp
Normal file
57
l1/sexp_effects/effects/xector_inset_blend.sexp
Normal file
@@ -0,0 +1,57 @@
|
||||
;; Inset blend - fade a smaller frame into a larger background
|
||||
;; Uses distance-based alpha for smooth transition (no hard edges)
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect xector_inset_blend
|
||||
:params (
|
||||
(x :type int :default 0 :desc "X position of inset")
|
||||
(y :type int :default 0 :desc "Y position of inset")
|
||||
(fade-width :type int :default 50 :desc "Width of fade region in pixels")
|
||||
(overlay :type frame :default nil :desc "The smaller frame to inset")
|
||||
)
|
||||
(let* (
|
||||
;; Get dimensions
|
||||
(bg-h (first (list (nth (list (red frame)) 0)))) ;; TODO: need image:height
|
||||
(bg-w bg-h) ;; placeholder
|
||||
|
||||
;; For now, create a simple centered circular blend
|
||||
;; Distance from center of overlay position
|
||||
(cx (+ x (/ (- bg-w (* 2 x)) 2)))
|
||||
(cy (+ y (/ (- bg-h (* 2 y)) 2)))
|
||||
|
||||
;; Get coordinates as xectors
|
||||
(px (x-coords frame))
|
||||
(py (y-coords frame))
|
||||
|
||||
;; Distance from center
|
||||
(dx (α- px cx))
|
||||
(dy (α- py cy))
|
||||
(dist (αsqrt (α+ (α* dx dx) (α* dy dy))))
|
||||
|
||||
;; Inner radius (fully overlay) and outer radius (fully background)
|
||||
(inner-r (- (/ bg-w 2) x fade-width))
|
||||
(outer-r (- (/ bg-w 2) x))
|
||||
|
||||
;; Blend factor: 1.0 inside inner-r, 0.0 outside outer-r, linear between
|
||||
(t (α/ (α- dist inner-r) fade-width))
|
||||
(blend (α- 1 (αclamp t 0 1)))
|
||||
|
||||
;; Extract channels from both frames
|
||||
(bg-r (red frame))
|
||||
(bg-g (green frame))
|
||||
(bg-b (blue frame)))
|
||||
|
||||
;; If overlay provided, blend it
|
||||
(if overlay
|
||||
(let* ((ov-r (red overlay))
|
||||
(ov-g (green overlay))
|
||||
(ov-b (blue overlay))
|
||||
;; Linear blend: result = bg * (1-blend) + overlay * blend
|
||||
(r-out (α+ (α* bg-r (α- 1 blend)) (α* ov-r blend)))
|
||||
(g-out (α+ (α* bg-g (α- 1 blend)) (α* ov-g blend)))
|
||||
(b-out (α+ (α* bg-b (α- 1 blend)) (α* ov-b blend))))
|
||||
(rgb r-out g-out b-out))
|
||||
;; No overlay - just show the blend mask for debugging
|
||||
(let ((mask-vis (α* blend 255)))
|
||||
(rgb mask-vis mask-vis mask-vis)))))
|
||||
27
l1/sexp_effects/effects/xector_threshold.sexp
Normal file
27
l1/sexp_effects/effects/xector_threshold.sexp
Normal file
@@ -0,0 +1,27 @@
|
||||
;; Threshold effect using xector operations
|
||||
;; Demonstrates where (conditional select) and β (reduction) for normalization
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect xector_threshold
|
||||
:params (
|
||||
(threshold :type float :default 0.5 :range [0 1] :desc "Brightness threshold (0-1)")
|
||||
(invert :type bool :default false :desc "Invert the threshold")
|
||||
)
|
||||
(let* (
|
||||
;; Get grayscale luminance as xector
|
||||
(luma (gray frame))
|
||||
|
||||
;; Normalize to 0-1 range
|
||||
(luma-norm (α/ luma 255))
|
||||
|
||||
;; Create boolean mask: pixels above threshold
|
||||
(mask (if invert
|
||||
(α< luma-norm threshold)
|
||||
(α>= luma-norm threshold)))
|
||||
|
||||
;; Use where to select: white (255) if above threshold, black (0) if below
|
||||
(out (where mask 255 0)))
|
||||
|
||||
;; Output as grayscale (same value for R, G, B)
|
||||
(rgb out out out)))
|
||||
36
l1/sexp_effects/effects/xector_vignette.sexp
Normal file
36
l1/sexp_effects/effects/xector_vignette.sexp
Normal file
@@ -0,0 +1,36 @@
|
||||
;; Vignette effect using xector operations
|
||||
;; Demonstrates α (element-wise) and β (reduction) patterns
|
||||
|
||||
(require-primitives "xector")
|
||||
|
||||
(define-effect xector_vignette
|
||||
:params (
|
||||
(strength :type float :default 0.5 :range [0 1])
|
||||
(radius :type float :default 1.0 :range [0.5 2])
|
||||
)
|
||||
(let* (
|
||||
;; Get normalized distance from center for each pixel
|
||||
(dist (dist-from-center frame))
|
||||
|
||||
;; Calculate max distance (corner distance)
|
||||
(max-dist (* (βmax dist) radius))
|
||||
|
||||
;; Calculate brightness factor per pixel: 1 - (dist/max-dist * strength)
|
||||
;; Using explicit α operators
|
||||
(factor (α- 1 (α* (α/ dist max-dist) strength)))
|
||||
|
||||
;; Clamp factor to [0, 1]
|
||||
(factor (αclamp factor 0 1))
|
||||
|
||||
;; Extract channels as xectors
|
||||
(r (red frame))
|
||||
(g (green frame))
|
||||
(b (blue frame))
|
||||
|
||||
;; Apply factor to each channel (implicit element-wise via Xector operators)
|
||||
(r-out (* r factor))
|
||||
(g-out (* g factor))
|
||||
(b-out (* b factor)))
|
||||
|
||||
;; Combine back to frame
|
||||
(rgb r-out g-out b-out)))
|
||||
8
l1/sexp_effects/effects/zoom.sexp
Normal file
8
l1/sexp_effects/effects/zoom.sexp
Normal file
@@ -0,0 +1,8 @@
|
||||
;; Zoom effect - zooms in/out from center
|
||||
(require-primitives "geometry")
|
||||
|
||||
(define-effect zoom
|
||||
:params (
|
||||
(amount :type int :default 1 :range [0.1 5])
|
||||
)
|
||||
(geometry:scale-img frame amount amount))
|
||||
1085
l1/sexp_effects/interpreter.py
Normal file
1085
l1/sexp_effects/interpreter.py
Normal file
File diff suppressed because it is too large
Load Diff
396
l1/sexp_effects/parser.py
Normal file
396
l1/sexp_effects/parser.py
Normal file
@@ -0,0 +1,396 @@
|
||||
"""
|
||||
S-expression parser for ArtDAG recipes and plans.
|
||||
|
||||
Supports:
|
||||
- Lists: (a b c)
|
||||
- Symbols: foo, bar-baz, ->
|
||||
- Keywords: :key
|
||||
- Strings: "hello world"
|
||||
- Numbers: 42, 3.14, -1.5
|
||||
- Comments: ; to end of line
|
||||
- Vectors: [a b c] (syntactic sugar for lists)
|
||||
- Maps: {:key1 val1 :key2 val2} (parsed as Python dicts)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Union
|
||||
import re
|
||||
|
||||
|
||||
@dataclass
|
||||
class Symbol:
|
||||
"""An unquoted symbol/identifier."""
|
||||
name: str
|
||||
|
||||
def __repr__(self):
|
||||
return f"Symbol({self.name!r})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Symbol):
|
||||
return self.name == other.name
|
||||
if isinstance(other, str):
|
||||
return self.name == other
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return hash(self.name)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Keyword:
|
||||
"""A keyword starting with colon."""
|
||||
name: str
|
||||
|
||||
def __repr__(self):
|
||||
return f"Keyword({self.name!r})"
|
||||
|
||||
def __eq__(self, other):
|
||||
if isinstance(other, Keyword):
|
||||
return self.name == other.name
|
||||
return False
|
||||
|
||||
def __hash__(self):
|
||||
return hash((':' , self.name))
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
"""Error during S-expression parsing."""
|
||||
def __init__(self, message: str, position: int = 0, line: int = 1, col: int = 1):
|
||||
self.position = position
|
||||
self.line = line
|
||||
self.col = col
|
||||
super().__init__(f"{message} at line {line}, column {col}")
|
||||
|
||||
|
||||
class Tokenizer:
|
||||
"""Tokenize S-expression text into tokens."""
|
||||
|
||||
# Token patterns
|
||||
WHITESPACE = re.compile(r'\s+')
|
||||
COMMENT = re.compile(r';[^\n]*')
|
||||
STRING = re.compile(r'"(?:[^"\\]|\\.)*"')
|
||||
NUMBER = re.compile(r'-?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?')
|
||||
KEYWORD = re.compile(r':[a-zA-Z_][a-zA-Z0-9_-]*')
|
||||
# Symbol pattern includes Greek letters α (alpha) and β (beta) for xector operations
|
||||
SYMBOL = re.compile(r'[a-zA-Z_*+\-><=/!?αβ²λ][a-zA-Z0-9_*+\-><=/!?.:αβ²λ]*')
|
||||
|
||||
def __init__(self, text: str):
|
||||
self.text = text
|
||||
self.pos = 0
|
||||
self.line = 1
|
||||
self.col = 1
|
||||
|
||||
def _advance(self, count: int = 1):
|
||||
"""Advance position, tracking line/column."""
|
||||
for _ in range(count):
|
||||
if self.pos < len(self.text):
|
||||
if self.text[self.pos] == '\n':
|
||||
self.line += 1
|
||||
self.col = 1
|
||||
else:
|
||||
self.col += 1
|
||||
self.pos += 1
|
||||
|
||||
def _skip_whitespace_and_comments(self):
|
||||
"""Skip whitespace and comments."""
|
||||
while self.pos < len(self.text):
|
||||
# Whitespace
|
||||
match = self.WHITESPACE.match(self.text, self.pos)
|
||||
if match:
|
||||
self._advance(match.end() - self.pos)
|
||||
continue
|
||||
|
||||
# Comments
|
||||
match = self.COMMENT.match(self.text, self.pos)
|
||||
if match:
|
||||
self._advance(match.end() - self.pos)
|
||||
continue
|
||||
|
||||
break
|
||||
|
||||
def peek(self) -> str | None:
|
||||
"""Peek at current character."""
|
||||
self._skip_whitespace_and_comments()
|
||||
if self.pos >= len(self.text):
|
||||
return None
|
||||
return self.text[self.pos]
|
||||
|
||||
def next_token(self) -> Any:
|
||||
"""Get the next token."""
|
||||
self._skip_whitespace_and_comments()
|
||||
|
||||
if self.pos >= len(self.text):
|
||||
return None
|
||||
|
||||
char = self.text[self.pos]
|
||||
start_line, start_col = self.line, self.col
|
||||
|
||||
# Single-character tokens (parens, brackets, braces)
|
||||
if char in '()[]{}':
|
||||
self._advance()
|
||||
return char
|
||||
|
||||
# String
|
||||
if char == '"':
|
||||
match = self.STRING.match(self.text, self.pos)
|
||||
if not match:
|
||||
raise ParseError("Unterminated string", self.pos, self.line, self.col)
|
||||
self._advance(match.end() - self.pos)
|
||||
# Parse escape sequences
|
||||
content = match.group()[1:-1]
|
||||
content = content.replace('\\n', '\n')
|
||||
content = content.replace('\\t', '\t')
|
||||
content = content.replace('\\"', '"')
|
||||
content = content.replace('\\\\', '\\')
|
||||
return content
|
||||
|
||||
# Keyword
|
||||
if char == ':':
|
||||
match = self.KEYWORD.match(self.text, self.pos)
|
||||
if match:
|
||||
self._advance(match.end() - self.pos)
|
||||
return Keyword(match.group()[1:]) # Strip leading colon
|
||||
raise ParseError(f"Invalid keyword", self.pos, self.line, self.col)
|
||||
|
||||
# Number (must check before symbol due to - prefix)
|
||||
if char.isdigit() or (char == '-' and self.pos + 1 < len(self.text) and
|
||||
(self.text[self.pos + 1].isdigit() or self.text[self.pos + 1] == '.')):
|
||||
match = self.NUMBER.match(self.text, self.pos)
|
||||
if match:
|
||||
self._advance(match.end() - self.pos)
|
||||
num_str = match.group()
|
||||
if '.' in num_str or 'e' in num_str or 'E' in num_str:
|
||||
return float(num_str)
|
||||
return int(num_str)
|
||||
|
||||
# Symbol
|
||||
match = self.SYMBOL.match(self.text, self.pos)
|
||||
if match:
|
||||
self._advance(match.end() - self.pos)
|
||||
return Symbol(match.group())
|
||||
|
||||
raise ParseError(f"Unexpected character: {char!r}", self.pos, self.line, self.col)
|
||||
|
||||
|
||||
def parse(text: str) -> Any:
|
||||
"""
|
||||
Parse an S-expression string into Python data structures.
|
||||
|
||||
Returns:
|
||||
Parsed S-expression as nested Python structures:
|
||||
- Lists become Python lists
|
||||
- Symbols become Symbol objects
|
||||
- Keywords become Keyword objects
|
||||
- Strings become Python strings
|
||||
- Numbers become int/float
|
||||
|
||||
Example:
|
||||
>>> parse('(recipe "test" :version "1.0")')
|
||||
[Symbol('recipe'), 'test', Keyword('version'), '1.0']
|
||||
"""
|
||||
tokenizer = Tokenizer(text)
|
||||
result = _parse_expr(tokenizer)
|
||||
|
||||
# Check for trailing content
|
||||
if tokenizer.peek() is not None:
|
||||
raise ParseError("Unexpected content after expression",
|
||||
tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def parse_all(text: str) -> List[Any]:
|
||||
"""
|
||||
Parse multiple S-expressions from a string.
|
||||
|
||||
Returns list of parsed expressions.
|
||||
"""
|
||||
tokenizer = Tokenizer(text)
|
||||
results = []
|
||||
|
||||
while tokenizer.peek() is not None:
|
||||
results.append(_parse_expr(tokenizer))
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def _parse_expr(tokenizer: Tokenizer) -> Any:
|
||||
"""Parse a single expression."""
|
||||
token = tokenizer.next_token()
|
||||
|
||||
if token is None:
|
||||
raise ParseError("Unexpected end of input", tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
# List
|
||||
if token == '(':
|
||||
return _parse_list(tokenizer, ')')
|
||||
|
||||
# Vector (sugar for list)
|
||||
if token == '[':
|
||||
return _parse_list(tokenizer, ']')
|
||||
|
||||
# Map/dict: {:key1 val1 :key2 val2}
|
||||
if token == '{':
|
||||
return _parse_map(tokenizer)
|
||||
|
||||
# Unexpected closers
|
||||
if isinstance(token, str) and token in ')]}':
|
||||
raise ParseError(f"Unexpected {token!r}", tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
# Atom
|
||||
return token
|
||||
|
||||
|
||||
def _parse_list(tokenizer: Tokenizer, closer: str) -> List[Any]:
|
||||
"""Parse a list until the closing delimiter."""
|
||||
items = []
|
||||
|
||||
while True:
|
||||
char = tokenizer.peek()
|
||||
|
||||
if char is None:
|
||||
raise ParseError(f"Unterminated list, expected {closer!r}",
|
||||
tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
if char == closer:
|
||||
tokenizer.next_token() # Consume closer
|
||||
return items
|
||||
|
||||
items.append(_parse_expr(tokenizer))
|
||||
|
||||
|
||||
def _parse_map(tokenizer: Tokenizer) -> Dict[str, Any]:
|
||||
"""Parse a map/dict: {:key1 val1 :key2 val2} -> {"key1": val1, "key2": val2}."""
|
||||
result = {}
|
||||
|
||||
while True:
|
||||
char = tokenizer.peek()
|
||||
|
||||
if char is None:
|
||||
raise ParseError("Unterminated map, expected '}'",
|
||||
tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
if char == '}':
|
||||
tokenizer.next_token() # Consume closer
|
||||
return result
|
||||
|
||||
# Parse key (should be a keyword like :key)
|
||||
key_token = _parse_expr(tokenizer)
|
||||
if isinstance(key_token, Keyword):
|
||||
key = key_token.name
|
||||
elif isinstance(key_token, str):
|
||||
key = key_token
|
||||
else:
|
||||
raise ParseError(f"Map key must be keyword or string, got {type(key_token).__name__}",
|
||||
tokenizer.pos, tokenizer.line, tokenizer.col)
|
||||
|
||||
# Parse value
|
||||
value = _parse_expr(tokenizer)
|
||||
result[key] = value
|
||||
|
||||
|
||||
def serialize(expr: Any, indent: int = 0, pretty: bool = False) -> str:
|
||||
"""
|
||||
Serialize a Python data structure back to S-expression format.
|
||||
|
||||
Args:
|
||||
expr: The expression to serialize
|
||||
indent: Current indentation level (for pretty printing)
|
||||
pretty: Whether to use pretty printing with newlines
|
||||
|
||||
Returns:
|
||||
S-expression string
|
||||
"""
|
||||
if isinstance(expr, list):
|
||||
if not expr:
|
||||
return "()"
|
||||
|
||||
if pretty:
|
||||
return _serialize_pretty(expr, indent)
|
||||
else:
|
||||
items = [serialize(item, indent, False) for item in expr]
|
||||
return "(" + " ".join(items) + ")"
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
return expr.name
|
||||
|
||||
if isinstance(expr, Keyword):
|
||||
return f":{expr.name}"
|
||||
|
||||
if isinstance(expr, str):
|
||||
# Escape special characters
|
||||
escaped = expr.replace('\\', '\\\\').replace('"', '\\"').replace('\n', '\\n').replace('\t', '\\t')
|
||||
return f'"{escaped}"'
|
||||
|
||||
if isinstance(expr, bool):
|
||||
return "true" if expr else "false"
|
||||
|
||||
if isinstance(expr, (int, float)):
|
||||
return str(expr)
|
||||
|
||||
if expr is None:
|
||||
return "nil"
|
||||
|
||||
if isinstance(expr, dict):
|
||||
# Serialize dict as property list: {:key1 val1 :key2 val2}
|
||||
items = []
|
||||
for k, v in expr.items():
|
||||
items.append(f":{k}")
|
||||
items.append(serialize(v, indent, pretty))
|
||||
return "{" + " ".join(items) + "}"
|
||||
|
||||
raise ValueError(f"Cannot serialize {type(expr).__name__}: {expr!r}")
|
||||
|
||||
|
||||
def _serialize_pretty(expr: List, indent: int) -> str:
|
||||
"""Pretty-print a list expression with smart formatting."""
|
||||
if not expr:
|
||||
return "()"
|
||||
|
||||
prefix = " " * indent
|
||||
inner_prefix = " " * (indent + 1)
|
||||
|
||||
# Check if this is a simple list that fits on one line
|
||||
simple = serialize(expr, indent, False)
|
||||
if len(simple) < 60 and '\n' not in simple:
|
||||
return simple
|
||||
|
||||
# Start building multiline output
|
||||
head = serialize(expr[0], indent + 1, False)
|
||||
parts = [f"({head}"]
|
||||
|
||||
i = 1
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
|
||||
# Group keyword-value pairs on same line
|
||||
if isinstance(item, Keyword) and i + 1 < len(expr):
|
||||
key = serialize(item, 0, False)
|
||||
val = serialize(expr[i + 1], indent + 1, False)
|
||||
|
||||
# If value is short, put on same line
|
||||
if len(val) < 50 and '\n' not in val:
|
||||
parts.append(f"{inner_prefix}{key} {val}")
|
||||
else:
|
||||
# Value is complex, serialize it pretty
|
||||
val_pretty = serialize(expr[i + 1], indent + 1, True)
|
||||
parts.append(f"{inner_prefix}{key} {val_pretty}")
|
||||
i += 2
|
||||
else:
|
||||
# Regular item
|
||||
item_str = serialize(item, indent + 1, True)
|
||||
parts.append(f"{inner_prefix}{item_str}")
|
||||
i += 1
|
||||
|
||||
return "\n".join(parts) + ")"
|
||||
|
||||
|
||||
def parse_file(path: str) -> Any:
|
||||
"""Parse an S-expression file (supports multiple top-level expressions)."""
|
||||
with open(path, 'r') as f:
|
||||
return parse_all(f.read())
|
||||
|
||||
|
||||
def to_sexp(obj: Any) -> str:
|
||||
"""Convert Python object back to S-expression string (alias for serialize)."""
|
||||
return serialize(obj)
|
||||
102
l1/sexp_effects/primitive_libs/__init__.py
Normal file
102
l1/sexp_effects/primitive_libs/__init__.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Primitive Libraries System
|
||||
|
||||
Provides modular loading of primitives. Core primitives are always available,
|
||||
additional primitive libraries can be loaded on-demand with scoped availability.
|
||||
|
||||
Usage in sexp:
|
||||
;; Load at recipe level - available throughout
|
||||
(primitives math :path "primitive_libs/math.py")
|
||||
|
||||
;; Or use with-primitives for scoped access
|
||||
(with-primitives "image"
|
||||
(blur frame 3)) ;; blur only available inside
|
||||
|
||||
;; Nested scopes work
|
||||
(with-primitives "math"
|
||||
(with-primitives "color"
|
||||
(hue-shift frame (* (sin t) 30))))
|
||||
|
||||
Library file format (primitive_libs/math.py):
|
||||
import math
|
||||
|
||||
def prim_sin(x): return math.sin(x)
|
||||
def prim_cos(x): return math.cos(x)
|
||||
|
||||
PRIMITIVES = {
|
||||
'sin': prim_sin,
|
||||
'cos': prim_cos,
|
||||
}
|
||||
"""
|
||||
|
||||
import importlib.util
|
||||
from pathlib import Path
|
||||
from typing import Dict, Callable, Any, Optional
|
||||
|
||||
# Cache of loaded primitive libraries
|
||||
_library_cache: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
# Core primitives - always available, cannot be overridden
|
||||
CORE_PRIMITIVES: Dict[str, Any] = {}
|
||||
|
||||
|
||||
def register_core_primitive(name: str, fn: Callable):
|
||||
"""Register a core primitive that's always available."""
|
||||
CORE_PRIMITIVES[name] = fn
|
||||
|
||||
|
||||
def load_primitive_library(name: str, path: Optional[str] = None) -> Dict[str, Any]:
|
||||
"""
|
||||
Load a primitive library by name or path.
|
||||
|
||||
Args:
|
||||
name: Library name (e.g., "math", "image", "color")
|
||||
path: Optional explicit path to library file
|
||||
|
||||
Returns:
|
||||
Dict of primitive name -> function
|
||||
"""
|
||||
# Check cache first
|
||||
cache_key = path or name
|
||||
if cache_key in _library_cache:
|
||||
return _library_cache[cache_key]
|
||||
|
||||
# Find library file
|
||||
if path:
|
||||
lib_path = Path(path)
|
||||
else:
|
||||
# Look in standard locations
|
||||
lib_dir = Path(__file__).parent
|
||||
lib_path = lib_dir / f"{name}.py"
|
||||
|
||||
if not lib_path.exists():
|
||||
raise ValueError(f"Primitive library '{name}' not found at {lib_path}")
|
||||
|
||||
if not lib_path.exists():
|
||||
raise ValueError(f"Primitive library file not found: {lib_path}")
|
||||
|
||||
# Load the module
|
||||
spec = importlib.util.spec_from_file_location(f"prim_lib_{name}", lib_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
|
||||
# Get PRIMITIVES dict from module
|
||||
if not hasattr(module, 'PRIMITIVES'):
|
||||
raise ValueError(f"Primitive library '{name}' missing PRIMITIVES dict")
|
||||
|
||||
primitives = module.PRIMITIVES
|
||||
|
||||
# Cache and return
|
||||
_library_cache[cache_key] = primitives
|
||||
return primitives
|
||||
|
||||
|
||||
def get_library_names() -> list:
|
||||
"""Get names of available primitive libraries."""
|
||||
lib_dir = Path(__file__).parent
|
||||
return [p.stem for p in lib_dir.glob("*.py") if p.stem != "__init__"]
|
||||
|
||||
|
||||
def clear_cache():
|
||||
"""Clear the library cache (useful for testing)."""
|
||||
_library_cache.clear()
|
||||
196
l1/sexp_effects/primitive_libs/arrays.py
Normal file
196
l1/sexp_effects/primitive_libs/arrays.py
Normal file
@@ -0,0 +1,196 @@
|
||||
"""
|
||||
Array Primitives Library
|
||||
|
||||
Vectorized operations on numpy arrays for coordinate transformations.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
|
||||
# Arithmetic
|
||||
def prim_arr_add(a, b):
|
||||
return np.add(a, b)
|
||||
|
||||
|
||||
def prim_arr_sub(a, b):
|
||||
return np.subtract(a, b)
|
||||
|
||||
|
||||
def prim_arr_mul(a, b):
|
||||
return np.multiply(a, b)
|
||||
|
||||
|
||||
def prim_arr_div(a, b):
|
||||
return np.divide(a, b)
|
||||
|
||||
|
||||
def prim_arr_mod(a, b):
|
||||
return np.mod(a, b)
|
||||
|
||||
|
||||
def prim_arr_neg(a):
|
||||
return np.negative(a)
|
||||
|
||||
|
||||
# Math functions
|
||||
def prim_arr_sin(a):
|
||||
return np.sin(a)
|
||||
|
||||
|
||||
def prim_arr_cos(a):
|
||||
return np.cos(a)
|
||||
|
||||
|
||||
def prim_arr_tan(a):
|
||||
return np.tan(a)
|
||||
|
||||
|
||||
def prim_arr_sqrt(a):
|
||||
return np.sqrt(np.maximum(a, 0))
|
||||
|
||||
|
||||
def prim_arr_pow(a, b):
|
||||
return np.power(a, b)
|
||||
|
||||
|
||||
def prim_arr_abs(a):
|
||||
return np.abs(a)
|
||||
|
||||
|
||||
def prim_arr_exp(a):
|
||||
return np.exp(a)
|
||||
|
||||
|
||||
def prim_arr_log(a):
|
||||
return np.log(np.maximum(a, 1e-10))
|
||||
|
||||
|
||||
def prim_arr_atan2(y, x):
|
||||
return np.arctan2(y, x)
|
||||
|
||||
|
||||
# Comparison / selection
|
||||
def prim_arr_min(a, b):
|
||||
return np.minimum(a, b)
|
||||
|
||||
|
||||
def prim_arr_max(a, b):
|
||||
return np.maximum(a, b)
|
||||
|
||||
|
||||
def prim_arr_clip(a, lo, hi):
|
||||
return np.clip(a, lo, hi)
|
||||
|
||||
|
||||
def prim_arr_where(cond, a, b):
|
||||
return np.where(cond, a, b)
|
||||
|
||||
|
||||
def prim_arr_floor(a):
|
||||
return np.floor(a)
|
||||
|
||||
|
||||
def prim_arr_ceil(a):
|
||||
return np.ceil(a)
|
||||
|
||||
|
||||
def prim_arr_round(a):
|
||||
return np.round(a)
|
||||
|
||||
|
||||
# Interpolation
|
||||
def prim_arr_lerp(a, b, t):
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def prim_arr_smoothstep(edge0, edge1, x):
|
||||
t = prim_arr_clip((x - edge0) / (edge1 - edge0), 0.0, 1.0)
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
# Creation
|
||||
def prim_arr_zeros(shape):
|
||||
return np.zeros(shape, dtype=np.float32)
|
||||
|
||||
|
||||
def prim_arr_ones(shape):
|
||||
return np.ones(shape, dtype=np.float32)
|
||||
|
||||
|
||||
def prim_arr_full(shape, value):
|
||||
return np.full(shape, value, dtype=np.float32)
|
||||
|
||||
|
||||
def prim_arr_arange(start, stop, step=1):
|
||||
return np.arange(start, stop, step, dtype=np.float32)
|
||||
|
||||
|
||||
def prim_arr_linspace(start, stop, num):
|
||||
return np.linspace(start, stop, num, dtype=np.float32)
|
||||
|
||||
|
||||
def prim_arr_meshgrid(x, y):
|
||||
return np.meshgrid(x, y)
|
||||
|
||||
|
||||
# Coordinate transforms
|
||||
def prim_polar_from_center(map_x, map_y, cx, cy):
|
||||
"""Convert Cartesian to polar coordinates centered at (cx, cy)."""
|
||||
dx = map_x - cx
|
||||
dy = map_y - cy
|
||||
r = np.sqrt(dx**2 + dy**2)
|
||||
theta = np.arctan2(dy, dx)
|
||||
return (r, theta)
|
||||
|
||||
|
||||
def prim_cart_from_polar(r, theta, cx, cy):
|
||||
"""Convert polar to Cartesian, adding center offset."""
|
||||
x = r * np.cos(theta) + cx
|
||||
y = r * np.sin(theta) + cy
|
||||
return (x, y)
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Arithmetic
|
||||
'arr+': prim_arr_add,
|
||||
'arr-': prim_arr_sub,
|
||||
'arr*': prim_arr_mul,
|
||||
'arr/': prim_arr_div,
|
||||
'arr-mod': prim_arr_mod,
|
||||
'arr-neg': prim_arr_neg,
|
||||
|
||||
# Math
|
||||
'arr-sin': prim_arr_sin,
|
||||
'arr-cos': prim_arr_cos,
|
||||
'arr-tan': prim_arr_tan,
|
||||
'arr-sqrt': prim_arr_sqrt,
|
||||
'arr-pow': prim_arr_pow,
|
||||
'arr-abs': prim_arr_abs,
|
||||
'arr-exp': prim_arr_exp,
|
||||
'arr-log': prim_arr_log,
|
||||
'arr-atan2': prim_arr_atan2,
|
||||
|
||||
# Selection
|
||||
'arr-min': prim_arr_min,
|
||||
'arr-max': prim_arr_max,
|
||||
'arr-clip': prim_arr_clip,
|
||||
'arr-where': prim_arr_where,
|
||||
'arr-floor': prim_arr_floor,
|
||||
'arr-ceil': prim_arr_ceil,
|
||||
'arr-round': prim_arr_round,
|
||||
|
||||
# Interpolation
|
||||
'arr-lerp': prim_arr_lerp,
|
||||
'arr-smoothstep': prim_arr_smoothstep,
|
||||
|
||||
# Creation
|
||||
'arr-zeros': prim_arr_zeros,
|
||||
'arr-ones': prim_arr_ones,
|
||||
'arr-full': prim_arr_full,
|
||||
'arr-arange': prim_arr_arange,
|
||||
'arr-linspace': prim_arr_linspace,
|
||||
'arr-meshgrid': prim_arr_meshgrid,
|
||||
|
||||
# Coordinates
|
||||
'polar-from-center': prim_polar_from_center,
|
||||
'cart-from-polar': prim_cart_from_polar,
|
||||
}
|
||||
388
l1/sexp_effects/primitive_libs/ascii.py
Normal file
388
l1/sexp_effects/primitive_libs/ascii.py
Normal file
@@ -0,0 +1,388 @@
|
||||
"""
|
||||
ASCII Art Primitives Library
|
||||
|
||||
ASCII art rendering with per-zone expression evaluation and cell effects.
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
from typing import Any, Dict, List, Optional, Callable
|
||||
import colorsys
|
||||
|
||||
|
||||
# Character sets
|
||||
CHAR_SETS = {
|
||||
"standard": " .:-=+*#%@",
|
||||
"blocks": " ░▒▓█",
|
||||
"simple": " .:oO@",
|
||||
"digits": "0123456789",
|
||||
"binary": "01",
|
||||
"ascii": " `.-':_,^=;><+!rc*/z?sLTv)J7(|Fi{C}fI31tlu[neoZ5Yxjya]2ESwqkP6h9d4VpOGbUAKXHm8RD#$Bg0MNWQ%&@",
|
||||
}
|
||||
|
||||
# Default font
|
||||
_default_font = None
|
||||
|
||||
|
||||
def _get_font(size: int):
|
||||
"""Get monospace font at given size."""
|
||||
global _default_font
|
||||
try:
|
||||
return ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", size)
|
||||
except:
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def _parse_color(color_str: str) -> tuple:
|
||||
"""Parse color string to RGB tuple."""
|
||||
if color_str.startswith('#'):
|
||||
hex_color = color_str[1:]
|
||||
if len(hex_color) == 3:
|
||||
hex_color = ''.join(c*2 for c in hex_color)
|
||||
return tuple(int(hex_color[i:i+2], 16) for i in (0, 2, 4))
|
||||
|
||||
colors = {
|
||||
'black': (0, 0, 0), 'white': (255, 255, 255),
|
||||
'red': (255, 0, 0), 'green': (0, 255, 0), 'blue': (0, 0, 255),
|
||||
'yellow': (255, 255, 0), 'cyan': (0, 255, 255), 'magenta': (255, 0, 255),
|
||||
'gray': (128, 128, 128), 'grey': (128, 128, 128),
|
||||
}
|
||||
return colors.get(color_str.lower(), (0, 0, 0))
|
||||
|
||||
|
||||
def _cell_sample(frame: np.ndarray, cell_size: int):
|
||||
"""Sample frame into cells, returning colors and luminances.
|
||||
|
||||
Uses cv2.resize with INTER_AREA (pixel-area averaging) which is
|
||||
~25x faster than numpy reshape+mean for block downsampling.
|
||||
"""
|
||||
h, w = frame.shape[:2]
|
||||
rows = h // cell_size
|
||||
cols = w // cell_size
|
||||
|
||||
# Crop to exact grid then block-average via cv2 area interpolation.
|
||||
cropped = frame[:rows * cell_size, :cols * cell_size]
|
||||
colors = cv2.resize(cropped, (cols, rows), interpolation=cv2.INTER_AREA)
|
||||
|
||||
luminances = ((0.299 * colors[:, :, 0] +
|
||||
0.587 * colors[:, :, 1] +
|
||||
0.114 * colors[:, :, 2]) / 255.0).astype(np.float32)
|
||||
|
||||
return colors, luminances
|
||||
|
||||
|
||||
def _luminance_to_char(lum: float, alphabet: str, contrast: float) -> str:
|
||||
"""Map luminance to character."""
|
||||
chars = CHAR_SETS.get(alphabet, alphabet)
|
||||
lum = ((lum - 0.5) * contrast + 0.5)
|
||||
lum = max(0, min(1, lum))
|
||||
idx = int(lum * (len(chars) - 1))
|
||||
return chars[idx]
|
||||
|
||||
|
||||
def _render_char_cell(char: str, cell_size: int, color: tuple, bg_color: tuple) -> np.ndarray:
|
||||
"""Render a single character to a cell image."""
|
||||
img = Image.new('RGB', (cell_size, cell_size), bg_color)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = _get_font(cell_size)
|
||||
|
||||
# Center the character
|
||||
bbox = draw.textbbox((0, 0), char, font=font)
|
||||
text_w = bbox[2] - bbox[0]
|
||||
text_h = bbox[3] - bbox[1]
|
||||
x = (cell_size - text_w) // 2
|
||||
y = (cell_size - text_h) // 2 - bbox[1]
|
||||
|
||||
draw.text((x, y), char, fill=color, font=font)
|
||||
return np.array(img)
|
||||
|
||||
|
||||
def prim_ascii_fx_zone(
|
||||
frame: np.ndarray,
|
||||
cols: int = 80,
|
||||
char_size: int = None,
|
||||
alphabet: str = "standard",
|
||||
color_mode: str = "color",
|
||||
background: str = "black",
|
||||
contrast: float = 1.5,
|
||||
char_hue = None,
|
||||
char_saturation = None,
|
||||
char_brightness = None,
|
||||
char_scale = None,
|
||||
char_rotation = None,
|
||||
char_jitter = None,
|
||||
cell_effect = None,
|
||||
energy: float = None,
|
||||
rotation_scale: float = 0,
|
||||
_interp = None,
|
||||
_env = None,
|
||||
**extra_params
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Render frame as ASCII art with per-zone effects.
|
||||
|
||||
Args:
|
||||
frame: Input image
|
||||
cols: Number of character columns
|
||||
char_size: Cell size in pixels (overrides cols if set)
|
||||
alphabet: Character set name or custom string
|
||||
color_mode: "color", "mono", "invert", or color name
|
||||
background: Background color name or hex
|
||||
contrast: Contrast for character selection
|
||||
char_hue/saturation/brightness/scale/rotation/jitter: Per-zone expressions
|
||||
cell_effect: Lambda (cell, zone) -> cell for per-cell effects
|
||||
energy: Energy value from audio analysis
|
||||
rotation_scale: Max rotation degrees
|
||||
_interp: Interpreter (auto-injected)
|
||||
_env: Environment (auto-injected)
|
||||
**extra_params: Additional params passed to zone dict
|
||||
"""
|
||||
h, w = frame.shape[:2]
|
||||
|
||||
# Calculate cell size
|
||||
if char_size is None or char_size == 0:
|
||||
cell_size = max(4, w // cols)
|
||||
else:
|
||||
cell_size = max(4, int(char_size))
|
||||
|
||||
# Sample cells
|
||||
colors, luminances = _cell_sample(frame, cell_size)
|
||||
rows, cols_actual = luminances.shape
|
||||
|
||||
# Parse background color
|
||||
bg_color = _parse_color(background)
|
||||
|
||||
# Create output image
|
||||
out_h = rows * cell_size
|
||||
out_w = cols_actual * cell_size
|
||||
output = np.full((out_h, out_w, 3), bg_color, dtype=np.uint8)
|
||||
|
||||
# Check if we have cell_effect
|
||||
has_cell_effect = cell_effect is not None
|
||||
|
||||
# Process each cell
|
||||
for r in range(rows):
|
||||
for c in range(cols_actual):
|
||||
lum = luminances[r, c]
|
||||
cell_color = tuple(colors[r, c])
|
||||
|
||||
# Build zone context
|
||||
zone = {
|
||||
'row': r,
|
||||
'col': c,
|
||||
'row-norm': r / max(1, rows - 1),
|
||||
'col-norm': c / max(1, cols_actual - 1),
|
||||
'lum': float(lum),
|
||||
'r': cell_color[0] / 255,
|
||||
'g': cell_color[1] / 255,
|
||||
'b': cell_color[2] / 255,
|
||||
'cell_size': cell_size,
|
||||
}
|
||||
|
||||
# Add HSV
|
||||
r_f, g_f, b_f = cell_color[0]/255, cell_color[1]/255, cell_color[2]/255
|
||||
hsv = colorsys.rgb_to_hsv(r_f, g_f, b_f)
|
||||
zone['hue'] = hsv[0] * 360
|
||||
zone['sat'] = hsv[1]
|
||||
|
||||
# Add energy and rotation_scale
|
||||
if energy is not None:
|
||||
zone['energy'] = energy
|
||||
zone['rotation_scale'] = rotation_scale
|
||||
|
||||
# Add extra params
|
||||
for k, v in extra_params.items():
|
||||
if isinstance(v, (int, float, str, bool)) or v is None:
|
||||
zone[k] = v
|
||||
|
||||
# Get character
|
||||
char = _luminance_to_char(lum, alphabet, contrast)
|
||||
zone['char'] = char
|
||||
|
||||
# Determine cell color based on mode
|
||||
if color_mode == "mono":
|
||||
render_color = (255, 255, 255)
|
||||
elif color_mode == "invert":
|
||||
render_color = tuple(255 - c for c in cell_color)
|
||||
elif color_mode == "color":
|
||||
render_color = cell_color
|
||||
else:
|
||||
render_color = _parse_color(color_mode)
|
||||
|
||||
zone['color'] = render_color
|
||||
|
||||
# Render character to cell
|
||||
cell_img = _render_char_cell(char, cell_size, render_color, bg_color)
|
||||
|
||||
# Apply cell_effect if provided
|
||||
if has_cell_effect and _interp is not None:
|
||||
cell_img = _apply_cell_effect(cell_img, zone, cell_effect, _interp, _env, extra_params)
|
||||
|
||||
# Paste cell to output
|
||||
y1, y2 = r * cell_size, (r + 1) * cell_size
|
||||
x1, x2 = c * cell_size, (c + 1) * cell_size
|
||||
output[y1:y2, x1:x2] = cell_img
|
||||
|
||||
# Resize to match input dimensions
|
||||
if output.shape[:2] != frame.shape[:2]:
|
||||
output = cv2.resize(output, (w, h), interpolation=cv2.INTER_LINEAR)
|
||||
|
||||
return output
|
||||
|
||||
|
||||
def _apply_cell_effect(cell_img, zone, cell_effect, interp, env, extra_params):
|
||||
"""Apply cell_effect lambda to a cell image.
|
||||
|
||||
cell_effect is a Lambda object with params and body.
|
||||
We create a child environment with zone variables and cell,
|
||||
then evaluate the lambda body.
|
||||
"""
|
||||
# Get Environment class from the interpreter's module
|
||||
Environment = type(env)
|
||||
|
||||
# Create child environment with zone variables
|
||||
cell_env = Environment(env)
|
||||
|
||||
# Bind zone variables
|
||||
for k, v in zone.items():
|
||||
cell_env.set(k, v)
|
||||
|
||||
# Also bind with zone- prefix for consistency
|
||||
cell_env.set('zone-row', zone.get('row', 0))
|
||||
cell_env.set('zone-col', zone.get('col', 0))
|
||||
cell_env.set('zone-row-norm', zone.get('row-norm', 0))
|
||||
cell_env.set('zone-col-norm', zone.get('col-norm', 0))
|
||||
cell_env.set('zone-lum', zone.get('lum', 0))
|
||||
cell_env.set('zone-sat', zone.get('sat', 0))
|
||||
cell_env.set('zone-hue', zone.get('hue', 0))
|
||||
cell_env.set('zone-r', zone.get('r', 0))
|
||||
cell_env.set('zone-g', zone.get('g', 0))
|
||||
cell_env.set('zone-b', zone.get('b', 0))
|
||||
|
||||
# Inject loaded effects as callable functions
|
||||
if hasattr(interp, 'effects'):
|
||||
for effect_name in interp.effects:
|
||||
def make_effect_fn(name):
|
||||
def effect_fn(frame, *args):
|
||||
params = {}
|
||||
if name == 'blur' and len(args) >= 1:
|
||||
params['radius'] = args[0]
|
||||
elif name == 'rotate' and len(args) >= 1:
|
||||
params['angle'] = args[0]
|
||||
elif name == 'brightness' and len(args) >= 1:
|
||||
params['amount'] = args[0]
|
||||
elif name == 'contrast' and len(args) >= 1:
|
||||
params['amount'] = args[0]
|
||||
elif name == 'saturation' and len(args) >= 1:
|
||||
params['amount'] = args[0]
|
||||
elif name == 'hue_shift' and len(args) >= 1:
|
||||
params['degrees'] = args[0]
|
||||
elif name == 'rgb_split' and len(args) >= 2:
|
||||
params['offset_x'] = args[0]
|
||||
params['offset_y'] = args[1]
|
||||
elif name == 'pixelate' and len(args) >= 1:
|
||||
params['size'] = args[0]
|
||||
elif name == 'invert':
|
||||
pass
|
||||
result, _ = interp.run_effect(name, frame, params, {})
|
||||
return result
|
||||
return effect_fn
|
||||
cell_env.set(effect_name, make_effect_fn(effect_name))
|
||||
|
||||
# Bind cell image and zone dict
|
||||
cell_env.set('cell', cell_img)
|
||||
cell_env.set('zone', zone)
|
||||
|
||||
# Evaluate the cell_effect lambda
|
||||
# Lambda has params and body - we need to bind the params then evaluate
|
||||
if hasattr(cell_effect, 'params') and hasattr(cell_effect, 'body'):
|
||||
# Bind lambda parameters: (lambda [cell zone] body)
|
||||
if len(cell_effect.params) >= 1:
|
||||
cell_env.set(cell_effect.params[0], cell_img)
|
||||
if len(cell_effect.params) >= 2:
|
||||
cell_env.set(cell_effect.params[1], zone)
|
||||
|
||||
result = interp.eval(cell_effect.body, cell_env)
|
||||
elif isinstance(cell_effect, list):
|
||||
# Raw S-expression lambda like (lambda [cell zone] body) or (fn [cell zone] body)
|
||||
# Check if it's a lambda expression
|
||||
head = cell_effect[0] if cell_effect else None
|
||||
head_name = head.name if head and hasattr(head, 'name') else str(head) if head else None
|
||||
is_lambda = head_name in ('lambda', 'fn')
|
||||
|
||||
if is_lambda:
|
||||
# (lambda [params...] body)
|
||||
params = cell_effect[1] if len(cell_effect) > 1 else []
|
||||
body = cell_effect[2] if len(cell_effect) > 2 else None
|
||||
|
||||
# Bind lambda parameters
|
||||
if isinstance(params, list) and len(params) >= 1:
|
||||
param_name = params[0].name if hasattr(params[0], 'name') else str(params[0])
|
||||
cell_env.set(param_name, cell_img)
|
||||
if isinstance(params, list) and len(params) >= 2:
|
||||
param_name = params[1].name if hasattr(params[1], 'name') else str(params[1])
|
||||
cell_env.set(param_name, zone)
|
||||
|
||||
result = interp.eval(body, cell_env) if body else cell_img
|
||||
else:
|
||||
# Some other expression - just evaluate it
|
||||
result = interp.eval(cell_effect, cell_env)
|
||||
elif callable(cell_effect):
|
||||
# It's a callable
|
||||
result = cell_effect(cell_img, zone)
|
||||
else:
|
||||
raise ValueError(f"cell_effect must be a Lambda, list, or callable, got {type(cell_effect)}")
|
||||
|
||||
if isinstance(result, np.ndarray) and result.shape == cell_img.shape:
|
||||
return result
|
||||
elif isinstance(result, np.ndarray):
|
||||
# Shape mismatch - resize to fit
|
||||
result = cv2.resize(result, (cell_img.shape[1], cell_img.shape[0]))
|
||||
return result
|
||||
|
||||
raise ValueError(f"cell_effect must return an image array, got {type(result)}")
|
||||
|
||||
|
||||
def _get_legacy_ascii_primitives():
|
||||
"""Import ASCII primitives from legacy primitives module.
|
||||
|
||||
These are loaded lazily to avoid import issues during module loading.
|
||||
By the time a primitive library is loaded, sexp_effects.primitives
|
||||
is already in sys.modules (imported by sexp_effects.__init__).
|
||||
"""
|
||||
from sexp_effects.primitives import (
|
||||
prim_cell_sample,
|
||||
prim_luminance_to_chars,
|
||||
prim_render_char_grid,
|
||||
prim_render_char_grid_fx,
|
||||
prim_alphabet_char,
|
||||
prim_alphabet_length,
|
||||
prim_map_char_grid,
|
||||
prim_map_colors,
|
||||
prim_make_char_grid,
|
||||
prim_set_char,
|
||||
prim_get_char,
|
||||
prim_char_grid_dimensions,
|
||||
cell_sample_extended,
|
||||
)
|
||||
return {
|
||||
'cell-sample': prim_cell_sample,
|
||||
'cell-sample-extended': cell_sample_extended,
|
||||
'luminance-to-chars': prim_luminance_to_chars,
|
||||
'render-char-grid': prim_render_char_grid,
|
||||
'render-char-grid-fx': prim_render_char_grid_fx,
|
||||
'alphabet-char': prim_alphabet_char,
|
||||
'alphabet-length': prim_alphabet_length,
|
||||
'map-char-grid': prim_map_char_grid,
|
||||
'map-colors': prim_map_colors,
|
||||
'make-char-grid': prim_make_char_grid,
|
||||
'set-char': prim_set_char,
|
||||
'get-char': prim_get_char,
|
||||
'char-grid-dimensions': prim_char_grid_dimensions,
|
||||
}
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
'ascii-fx-zone': prim_ascii_fx_zone,
|
||||
**_get_legacy_ascii_primitives(),
|
||||
}
|
||||
116
l1/sexp_effects/primitive_libs/blending.py
Normal file
116
l1/sexp_effects/primitive_libs/blending.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Blending Primitives Library
|
||||
|
||||
Image blending and compositing operations.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
|
||||
def prim_blend_images(a, b, alpha):
|
||||
"""Blend two images: a * (1-alpha) + b * alpha."""
|
||||
alpha = max(0.0, min(1.0, alpha))
|
||||
return (a.astype(float) * (1 - alpha) + b.astype(float) * alpha).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_blend_mode(a, b, mode):
|
||||
"""Blend using Photoshop-style blend modes."""
|
||||
a = a.astype(float) / 255
|
||||
b = b.astype(float) / 255
|
||||
|
||||
if mode == "multiply":
|
||||
result = a * b
|
||||
elif mode == "screen":
|
||||
result = 1 - (1 - a) * (1 - b)
|
||||
elif mode == "overlay":
|
||||
mask = a < 0.5
|
||||
result = np.where(mask, 2 * a * b, 1 - 2 * (1 - a) * (1 - b))
|
||||
elif mode == "soft-light":
|
||||
mask = b < 0.5
|
||||
result = np.where(mask,
|
||||
a - (1 - 2 * b) * a * (1 - a),
|
||||
a + (2 * b - 1) * (np.sqrt(a) - a))
|
||||
elif mode == "hard-light":
|
||||
mask = b < 0.5
|
||||
result = np.where(mask, 2 * a * b, 1 - 2 * (1 - a) * (1 - b))
|
||||
elif mode == "color-dodge":
|
||||
result = np.clip(a / (1 - b + 0.001), 0, 1)
|
||||
elif mode == "color-burn":
|
||||
result = 1 - np.clip((1 - a) / (b + 0.001), 0, 1)
|
||||
elif mode == "difference":
|
||||
result = np.abs(a - b)
|
||||
elif mode == "exclusion":
|
||||
result = a + b - 2 * a * b
|
||||
elif mode == "add":
|
||||
result = np.clip(a + b, 0, 1)
|
||||
elif mode == "subtract":
|
||||
result = np.clip(a - b, 0, 1)
|
||||
elif mode == "darken":
|
||||
result = np.minimum(a, b)
|
||||
elif mode == "lighten":
|
||||
result = np.maximum(a, b)
|
||||
else:
|
||||
# Default to normal (just return b)
|
||||
result = b
|
||||
|
||||
return (result * 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_mask(img, mask_img):
|
||||
"""Apply grayscale mask to image (white=opaque, black=transparent)."""
|
||||
if len(mask_img.shape) == 3:
|
||||
mask = mask_img[:, :, 0].astype(float) / 255
|
||||
else:
|
||||
mask = mask_img.astype(float) / 255
|
||||
|
||||
mask = mask[:, :, np.newaxis]
|
||||
return (img.astype(float) * mask).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_alpha_composite(base, overlay, alpha_channel):
|
||||
"""Composite overlay onto base using alpha channel."""
|
||||
if len(alpha_channel.shape) == 3:
|
||||
alpha = alpha_channel[:, :, 0].astype(float) / 255
|
||||
else:
|
||||
alpha = alpha_channel.astype(float) / 255
|
||||
|
||||
alpha = alpha[:, :, np.newaxis]
|
||||
result = base.astype(float) * (1 - alpha) + overlay.astype(float) * alpha
|
||||
return result.astype(np.uint8)
|
||||
|
||||
|
||||
def prim_overlay(base, overlay, x, y, alpha=1.0):
|
||||
"""Overlay image at position (x, y) with optional alpha."""
|
||||
result = base.copy()
|
||||
x, y = int(x), int(y)
|
||||
oh, ow = overlay.shape[:2]
|
||||
bh, bw = base.shape[:2]
|
||||
|
||||
# Clip to bounds
|
||||
sx1 = max(0, -x)
|
||||
sy1 = max(0, -y)
|
||||
dx1 = max(0, x)
|
||||
dy1 = max(0, y)
|
||||
sx2 = min(ow, bw - x)
|
||||
sy2 = min(oh, bh - y)
|
||||
|
||||
if sx2 > sx1 and sy2 > sy1:
|
||||
src = overlay[sy1:sy2, sx1:sx2]
|
||||
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
|
||||
blended = (dst.astype(float) * (1 - alpha) + src.astype(float) * alpha)
|
||||
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(np.uint8)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Basic blending
|
||||
'blend-images': prim_blend_images,
|
||||
'blend-mode': prim_blend_mode,
|
||||
|
||||
# Masking
|
||||
'mask': prim_mask,
|
||||
'alpha-composite': prim_alpha_composite,
|
||||
|
||||
# Overlay
|
||||
'overlay': prim_overlay,
|
||||
}
|
||||
220
l1/sexp_effects/primitive_libs/blending_gpu.py
Normal file
220
l1/sexp_effects/primitive_libs/blending_gpu.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
GPU-Accelerated Blending Primitives Library
|
||||
|
||||
Uses CuPy for CUDA-accelerated image blending and compositing.
|
||||
Keeps frames on GPU when STREAMING_GPU_PERSIST=1 for maximum performance.
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
# Try to import CuPy for GPU acceleration
|
||||
try:
|
||||
import cupy as cp
|
||||
GPU_AVAILABLE = True
|
||||
print("[blending_gpu] CuPy GPU acceleration enabled")
|
||||
except ImportError:
|
||||
cp = np
|
||||
GPU_AVAILABLE = False
|
||||
print("[blending_gpu] CuPy not available, using CPU fallback")
|
||||
|
||||
# GPU persistence mode - keep frames on GPU between operations
|
||||
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
|
||||
if GPU_AVAILABLE and GPU_PERSIST:
|
||||
print("[blending_gpu] GPU persistence enabled - frames stay on GPU")
|
||||
|
||||
|
||||
def _to_gpu(img):
|
||||
"""Move image to GPU if available."""
|
||||
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
|
||||
return cp.asarray(img)
|
||||
return img
|
||||
|
||||
|
||||
def _to_cpu(img):
|
||||
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
|
||||
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
|
||||
return cp.asnumpy(img)
|
||||
return img
|
||||
|
||||
|
||||
def _get_xp(img):
|
||||
"""Get the array module (numpy or cupy) for the given image."""
|
||||
if GPU_AVAILABLE and isinstance(img, cp.ndarray):
|
||||
return cp
|
||||
return np
|
||||
|
||||
|
||||
def prim_blend_images(a, b, alpha):
|
||||
"""Blend two images: a * (1-alpha) + b * alpha."""
|
||||
alpha = max(0.0, min(1.0, float(alpha)))
|
||||
|
||||
if GPU_AVAILABLE:
|
||||
a_gpu = _to_gpu(a)
|
||||
b_gpu = _to_gpu(b)
|
||||
result = (a_gpu.astype(cp.float32) * (1 - alpha) + b_gpu.astype(cp.float32) * alpha).astype(cp.uint8)
|
||||
return _to_cpu(result)
|
||||
|
||||
return (a.astype(float) * (1 - alpha) + b.astype(float) * alpha).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_blend_mode(a, b, mode):
|
||||
"""Blend using Photoshop-style blend modes."""
|
||||
if GPU_AVAILABLE:
|
||||
a_gpu = _to_gpu(a).astype(cp.float32) / 255
|
||||
b_gpu = _to_gpu(b).astype(cp.float32) / 255
|
||||
xp = cp
|
||||
else:
|
||||
a_gpu = a.astype(float) / 255
|
||||
b_gpu = b.astype(float) / 255
|
||||
xp = np
|
||||
|
||||
if mode == "multiply":
|
||||
result = a_gpu * b_gpu
|
||||
elif mode == "screen":
|
||||
result = 1 - (1 - a_gpu) * (1 - b_gpu)
|
||||
elif mode == "overlay":
|
||||
mask = a_gpu < 0.5
|
||||
result = xp.where(mask, 2 * a_gpu * b_gpu, 1 - 2 * (1 - a_gpu) * (1 - b_gpu))
|
||||
elif mode == "soft-light":
|
||||
mask = b_gpu < 0.5
|
||||
result = xp.where(mask,
|
||||
a_gpu - (1 - 2 * b_gpu) * a_gpu * (1 - a_gpu),
|
||||
a_gpu + (2 * b_gpu - 1) * (xp.sqrt(a_gpu) - a_gpu))
|
||||
elif mode == "hard-light":
|
||||
mask = b_gpu < 0.5
|
||||
result = xp.where(mask, 2 * a_gpu * b_gpu, 1 - 2 * (1 - a_gpu) * (1 - b_gpu))
|
||||
elif mode == "color-dodge":
|
||||
result = xp.clip(a_gpu / (1 - b_gpu + 0.001), 0, 1)
|
||||
elif mode == "color-burn":
|
||||
result = 1 - xp.clip((1 - a_gpu) / (b_gpu + 0.001), 0, 1)
|
||||
elif mode == "difference":
|
||||
result = xp.abs(a_gpu - b_gpu)
|
||||
elif mode == "exclusion":
|
||||
result = a_gpu + b_gpu - 2 * a_gpu * b_gpu
|
||||
elif mode == "add":
|
||||
result = xp.clip(a_gpu + b_gpu, 0, 1)
|
||||
elif mode == "subtract":
|
||||
result = xp.clip(a_gpu - b_gpu, 0, 1)
|
||||
elif mode == "darken":
|
||||
result = xp.minimum(a_gpu, b_gpu)
|
||||
elif mode == "lighten":
|
||||
result = xp.maximum(a_gpu, b_gpu)
|
||||
else:
|
||||
# Default to normal (just return b)
|
||||
result = b_gpu
|
||||
|
||||
result = (result * 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
|
||||
|
||||
def prim_mask(img, mask_img):
|
||||
"""Apply grayscale mask to image (white=opaque, black=transparent)."""
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img)
|
||||
mask_gpu = _to_gpu(mask_img)
|
||||
|
||||
if len(mask_gpu.shape) == 3:
|
||||
mask = mask_gpu[:, :, 0].astype(cp.float32) / 255
|
||||
else:
|
||||
mask = mask_gpu.astype(cp.float32) / 255
|
||||
|
||||
mask = mask[:, :, cp.newaxis]
|
||||
result = (img_gpu.astype(cp.float32) * mask).astype(cp.uint8)
|
||||
return _to_cpu(result)
|
||||
|
||||
if len(mask_img.shape) == 3:
|
||||
mask = mask_img[:, :, 0].astype(float) / 255
|
||||
else:
|
||||
mask = mask_img.astype(float) / 255
|
||||
|
||||
mask = mask[:, :, np.newaxis]
|
||||
return (img.astype(float) * mask).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_alpha_composite(base, overlay, alpha_channel):
|
||||
"""Composite overlay onto base using alpha channel."""
|
||||
if GPU_AVAILABLE:
|
||||
base_gpu = _to_gpu(base)
|
||||
overlay_gpu = _to_gpu(overlay)
|
||||
alpha_gpu = _to_gpu(alpha_channel)
|
||||
|
||||
if len(alpha_gpu.shape) == 3:
|
||||
alpha = alpha_gpu[:, :, 0].astype(cp.float32) / 255
|
||||
else:
|
||||
alpha = alpha_gpu.astype(cp.float32) / 255
|
||||
|
||||
alpha = alpha[:, :, cp.newaxis]
|
||||
result = base_gpu.astype(cp.float32) * (1 - alpha) + overlay_gpu.astype(cp.float32) * alpha
|
||||
return _to_cpu(result.astype(cp.uint8))
|
||||
|
||||
if len(alpha_channel.shape) == 3:
|
||||
alpha = alpha_channel[:, :, 0].astype(float) / 255
|
||||
else:
|
||||
alpha = alpha_channel.astype(float) / 255
|
||||
|
||||
alpha = alpha[:, :, np.newaxis]
|
||||
result = base.astype(float) * (1 - alpha) + overlay.astype(float) * alpha
|
||||
return result.astype(np.uint8)
|
||||
|
||||
|
||||
def prim_overlay(base, overlay, x, y, alpha=1.0):
|
||||
"""Overlay image at position (x, y) with optional alpha."""
|
||||
if GPU_AVAILABLE:
|
||||
base_gpu = _to_gpu(base)
|
||||
overlay_gpu = _to_gpu(overlay)
|
||||
result = base_gpu.copy()
|
||||
|
||||
x, y = int(x), int(y)
|
||||
oh, ow = overlay_gpu.shape[:2]
|
||||
bh, bw = base_gpu.shape[:2]
|
||||
|
||||
# Clip to bounds
|
||||
sx1 = max(0, -x)
|
||||
sy1 = max(0, -y)
|
||||
dx1 = max(0, x)
|
||||
dy1 = max(0, y)
|
||||
sx2 = min(ow, bw - x)
|
||||
sy2 = min(oh, bh - y)
|
||||
|
||||
if sx2 > sx1 and sy2 > sy1:
|
||||
src = overlay_gpu[sy1:sy2, sx1:sx2]
|
||||
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
|
||||
blended = (dst.astype(cp.float32) * (1 - alpha) + src.astype(cp.float32) * alpha)
|
||||
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(cp.uint8)
|
||||
|
||||
return _to_cpu(result)
|
||||
|
||||
result = base.copy()
|
||||
x, y = int(x), int(y)
|
||||
oh, ow = overlay.shape[:2]
|
||||
bh, bw = base.shape[:2]
|
||||
|
||||
# Clip to bounds
|
||||
sx1 = max(0, -x)
|
||||
sy1 = max(0, -y)
|
||||
dx1 = max(0, x)
|
||||
dy1 = max(0, y)
|
||||
sx2 = min(ow, bw - x)
|
||||
sy2 = min(oh, bh - y)
|
||||
|
||||
if sx2 > sx1 and sy2 > sy1:
|
||||
src = overlay[sy1:sy2, sx1:sx2]
|
||||
dst = result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)]
|
||||
blended = (dst.astype(float) * (1 - alpha) + src.astype(float) * alpha)
|
||||
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = blended.astype(np.uint8)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Basic blending
|
||||
'blend-images': prim_blend_images,
|
||||
'blend-mode': prim_blend_mode,
|
||||
|
||||
# Masking
|
||||
'mask': prim_mask,
|
||||
'alpha-composite': prim_alpha_composite,
|
||||
|
||||
# Overlay
|
||||
'overlay': prim_overlay,
|
||||
}
|
||||
137
l1/sexp_effects/primitive_libs/color.py
Normal file
137
l1/sexp_effects/primitive_libs/color.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""
|
||||
Color Primitives Library
|
||||
|
||||
Color manipulation: RGB, HSV, blending, luminance.
|
||||
"""
|
||||
import numpy as np
|
||||
import colorsys
|
||||
|
||||
|
||||
def prim_rgb(r, g, b):
|
||||
"""Create RGB color as [r, g, b] (0-255)."""
|
||||
return [int(max(0, min(255, r))),
|
||||
int(max(0, min(255, g))),
|
||||
int(max(0, min(255, b)))]
|
||||
|
||||
|
||||
def prim_red(c):
|
||||
return c[0]
|
||||
|
||||
|
||||
def prim_green(c):
|
||||
return c[1]
|
||||
|
||||
|
||||
def prim_blue(c):
|
||||
return c[2]
|
||||
|
||||
|
||||
def prim_luminance(c):
|
||||
"""Perceived luminance (0-1) using standard weights."""
|
||||
return (0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2]) / 255
|
||||
|
||||
|
||||
def prim_rgb_to_hsv(c):
|
||||
"""Convert RGB [0-255] to HSV [h:0-360, s:0-1, v:0-1]."""
|
||||
r, g, b = c[0] / 255, c[1] / 255, c[2] / 255
|
||||
h, s, v = colorsys.rgb_to_hsv(r, g, b)
|
||||
return [h * 360, s, v]
|
||||
|
||||
|
||||
def prim_hsv_to_rgb(hsv):
|
||||
"""Convert HSV [h:0-360, s:0-1, v:0-1] to RGB [0-255]."""
|
||||
h, s, v = hsv[0] / 360, hsv[1], hsv[2]
|
||||
r, g, b = colorsys.hsv_to_rgb(h, s, v)
|
||||
return [int(r * 255), int(g * 255), int(b * 255)]
|
||||
|
||||
|
||||
def prim_rgb_to_hsl(c):
|
||||
"""Convert RGB [0-255] to HSL [h:0-360, s:0-1, l:0-1]."""
|
||||
r, g, b = c[0] / 255, c[1] / 255, c[2] / 255
|
||||
h, l, s = colorsys.rgb_to_hls(r, g, b)
|
||||
return [h * 360, s, l]
|
||||
|
||||
|
||||
def prim_hsl_to_rgb(hsl):
|
||||
"""Convert HSL [h:0-360, s:0-1, l:0-1] to RGB [0-255]."""
|
||||
h, s, l = hsl[0] / 360, hsl[1], hsl[2]
|
||||
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
||||
return [int(r * 255), int(g * 255), int(b * 255)]
|
||||
|
||||
|
||||
def prim_blend_color(c1, c2, alpha):
|
||||
"""Blend two colors: c1 * (1-alpha) + c2 * alpha."""
|
||||
return [int(c1[i] * (1 - alpha) + c2[i] * alpha) for i in range(3)]
|
||||
|
||||
|
||||
def prim_average_color(img):
|
||||
"""Get average color of an image."""
|
||||
mean = np.mean(img, axis=(0, 1))
|
||||
return [int(mean[0]), int(mean[1]), int(mean[2])]
|
||||
|
||||
|
||||
def prim_dominant_color(img, k=1):
|
||||
"""Get dominant color using k-means (simplified: just average for now)."""
|
||||
return prim_average_color(img)
|
||||
|
||||
|
||||
def prim_invert_color(c):
|
||||
"""Invert a color."""
|
||||
return [255 - c[0], 255 - c[1], 255 - c[2]]
|
||||
|
||||
|
||||
def prim_grayscale_color(c):
|
||||
"""Convert color to grayscale."""
|
||||
gray = int(0.299 * c[0] + 0.587 * c[1] + 0.114 * c[2])
|
||||
return [gray, gray, gray]
|
||||
|
||||
|
||||
def prim_saturate(c, amount):
|
||||
"""Adjust saturation of color. amount=0 is grayscale, 1 is unchanged, >1 is more saturated."""
|
||||
hsv = prim_rgb_to_hsv(c)
|
||||
hsv[1] = max(0, min(1, hsv[1] * amount))
|
||||
return prim_hsv_to_rgb(hsv)
|
||||
|
||||
|
||||
def prim_brighten(c, amount):
|
||||
"""Adjust brightness. amount=0 is black, 1 is unchanged, >1 is brighter."""
|
||||
return [int(max(0, min(255, c[i] * amount))) for i in range(3)]
|
||||
|
||||
|
||||
def prim_shift_hue(c, degrees):
|
||||
"""Shift hue by degrees."""
|
||||
hsv = prim_rgb_to_hsv(c)
|
||||
hsv[0] = (hsv[0] + degrees) % 360
|
||||
return prim_hsv_to_rgb(hsv)
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Construction
|
||||
'rgb': prim_rgb,
|
||||
|
||||
# Component access
|
||||
'red': prim_red,
|
||||
'green': prim_green,
|
||||
'blue': prim_blue,
|
||||
'luminance': prim_luminance,
|
||||
|
||||
# Color space conversion
|
||||
'rgb->hsv': prim_rgb_to_hsv,
|
||||
'hsv->rgb': prim_hsv_to_rgb,
|
||||
'rgb->hsl': prim_rgb_to_hsl,
|
||||
'hsl->rgb': prim_hsl_to_rgb,
|
||||
|
||||
# Blending
|
||||
'blend-color': prim_blend_color,
|
||||
|
||||
# Analysis
|
||||
'average-color': prim_average_color,
|
||||
'dominant-color': prim_dominant_color,
|
||||
|
||||
# Manipulation
|
||||
'invert-color': prim_invert_color,
|
||||
'grayscale-color': prim_grayscale_color,
|
||||
'saturate': prim_saturate,
|
||||
'brighten': prim_brighten,
|
||||
'shift-hue': prim_shift_hue,
|
||||
}
|
||||
109
l1/sexp_effects/primitive_libs/color_ops.py
Normal file
109
l1/sexp_effects/primitive_libs/color_ops.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Color Operations Primitives Library
|
||||
|
||||
Vectorized color adjustments: brightness, contrast, saturation, invert, HSV.
|
||||
These operate on entire images for fast processing.
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
|
||||
def _to_numpy(img):
|
||||
"""Convert GPU frames or CuPy arrays to numpy for CPU processing."""
|
||||
# Handle GPUFrame objects
|
||||
if hasattr(img, 'cpu'):
|
||||
return img.cpu
|
||||
# Handle CuPy arrays
|
||||
if hasattr(img, 'get'):
|
||||
return img.get()
|
||||
return img
|
||||
|
||||
|
||||
def prim_adjust(img, brightness=0, contrast=1):
|
||||
"""Adjust brightness and contrast. Brightness: -255 to 255, Contrast: 0 to 3+."""
|
||||
img = _to_numpy(img)
|
||||
result = (img.astype(np.float32) - 128) * contrast + 128 + brightness
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_mix_gray(img_raw, amount):
|
||||
"""Mix image with its grayscale version. 0=original, 1=grayscale."""
|
||||
img = _to_numpy(img_raw)
|
||||
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
gray_rgb = np.stack([gray, gray, gray], axis=-1)
|
||||
result = img.astype(np.float32) * (1 - amount) + gray_rgb * amount
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_invert_img(img):
|
||||
"""Invert all pixel values."""
|
||||
img = _to_numpy(img)
|
||||
return (255 - img).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_shift_hsv(img, h=0, s=1, v=1):
|
||||
"""Shift HSV: h=degrees offset, s/v=multipliers."""
|
||||
img = _to_numpy(img)
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
|
||||
hsv[:, :, 0] = (hsv[:, :, 0] + h / 2) % 180
|
||||
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * s, 0, 255)
|
||||
hsv[:, :, 2] = np.clip(hsv[:, :, 2] * v, 0, 255)
|
||||
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
|
||||
|
||||
|
||||
def prim_add_noise(img, amount):
|
||||
"""Add gaussian noise to image."""
|
||||
img = _to_numpy(img)
|
||||
noise = np.random.normal(0, amount, img.shape)
|
||||
result = img.astype(np.float32) + noise
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_quantize(img, levels):
|
||||
"""Reduce to N color levels per channel."""
|
||||
img = _to_numpy(img)
|
||||
levels = max(2, int(levels))
|
||||
factor = 256 / levels
|
||||
result = (img // factor) * factor + factor // 2
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_sepia(img, intensity=1.0):
|
||||
"""Apply sepia tone effect."""
|
||||
img = _to_numpy(img)
|
||||
sepia_matrix = np.array([
|
||||
[0.393, 0.769, 0.189],
|
||||
[0.349, 0.686, 0.168],
|
||||
[0.272, 0.534, 0.131]
|
||||
])
|
||||
sepia = np.dot(img, sepia_matrix.T)
|
||||
result = img.astype(np.float32) * (1 - intensity) + sepia * intensity
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_grayscale(img):
|
||||
"""Convert to grayscale (still RGB output)."""
|
||||
img = _to_numpy(img)
|
||||
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
return np.stack([gray, gray, gray], axis=-1).astype(np.uint8)
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Brightness/Contrast
|
||||
'adjust': prim_adjust,
|
||||
|
||||
# Saturation
|
||||
'mix-gray': prim_mix_gray,
|
||||
'grayscale': prim_grayscale,
|
||||
|
||||
# HSV manipulation
|
||||
'shift-hsv': prim_shift_hsv,
|
||||
|
||||
# Inversion
|
||||
'invert-img': prim_invert_img,
|
||||
|
||||
# Effects
|
||||
'add-noise': prim_add_noise,
|
||||
'quantize': prim_quantize,
|
||||
'sepia': prim_sepia,
|
||||
}
|
||||
280
l1/sexp_effects/primitive_libs/color_ops_gpu.py
Normal file
280
l1/sexp_effects/primitive_libs/color_ops_gpu.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
GPU-Accelerated Color Operations Library
|
||||
|
||||
Uses CuPy for CUDA-accelerated color transforms.
|
||||
|
||||
Performance Mode:
|
||||
- Set STREAMING_GPU_PERSIST=1 to keep frames on GPU between operations
|
||||
- This dramatically improves performance by avoiding CPU<->GPU transfers
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
# Try to import CuPy for GPU acceleration
|
||||
try:
|
||||
import cupy as cp
|
||||
GPU_AVAILABLE = True
|
||||
print("[color_ops_gpu] CuPy GPU acceleration enabled")
|
||||
except ImportError:
|
||||
cp = np
|
||||
GPU_AVAILABLE = False
|
||||
print("[color_ops_gpu] CuPy not available, using CPU fallback")
|
||||
|
||||
# GPU persistence mode - keep frames on GPU between operations
|
||||
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
|
||||
if GPU_AVAILABLE and GPU_PERSIST:
|
||||
print("[color_ops_gpu] GPU persistence enabled - frames stay on GPU")
|
||||
|
||||
|
||||
def _to_gpu(img):
|
||||
"""Move image to GPU if available."""
|
||||
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
|
||||
return cp.asarray(img)
|
||||
return img
|
||||
|
||||
|
||||
def _to_cpu(img):
|
||||
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
|
||||
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
|
||||
return cp.asnumpy(img)
|
||||
return img
|
||||
|
||||
|
||||
def prim_invert(img):
|
||||
"""Invert image colors."""
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img)
|
||||
return _to_cpu(255 - img_gpu)
|
||||
return 255 - img
|
||||
|
||||
|
||||
def prim_grayscale(img):
|
||||
"""Convert to grayscale."""
|
||||
if img.ndim != 3:
|
||||
return img
|
||||
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img.astype(np.float32))
|
||||
# Standard luminance weights
|
||||
gray = 0.299 * img_gpu[:, :, 0] + 0.587 * img_gpu[:, :, 1] + 0.114 * img_gpu[:, :, 2]
|
||||
gray = cp.clip(gray, 0, 255).astype(cp.uint8)
|
||||
# Stack to 3 channels
|
||||
result = cp.stack([gray, gray, gray], axis=2)
|
||||
return _to_cpu(result)
|
||||
|
||||
gray = 0.299 * img[:, :, 0] + 0.587 * img[:, :, 1] + 0.114 * img[:, :, 2]
|
||||
gray = np.clip(gray, 0, 255).astype(np.uint8)
|
||||
return np.stack([gray, gray, gray], axis=2)
|
||||
|
||||
|
||||
def prim_brightness(img, factor=1.0):
|
||||
"""Adjust brightness by factor."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img.astype(np.float32))
|
||||
result = xp.clip(img_gpu * factor, 0, 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
return np.clip(img.astype(np.float32) * factor, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_contrast(img, factor=1.0):
|
||||
"""Adjust contrast around midpoint."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img.astype(np.float32))
|
||||
result = xp.clip((img_gpu - 128) * factor + 128, 0, 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
return np.clip((img.astype(np.float32) - 128) * factor + 128, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
# CUDA kernel for HSV hue shift
|
||||
if GPU_AVAILABLE:
|
||||
_hue_shift_kernel = cp.RawKernel(r'''
|
||||
extern "C" __global__
|
||||
void hue_shift(unsigned char* img, int width, int height, float shift) {
|
||||
int x = blockDim.x * blockIdx.x + threadIdx.x;
|
||||
int y = blockDim.y * blockIdx.y + threadIdx.y;
|
||||
|
||||
if (x >= width || y >= height) return;
|
||||
|
||||
int idx = (y * width + x) * 3;
|
||||
|
||||
// Get RGB
|
||||
float r = img[idx] / 255.0f;
|
||||
float g = img[idx + 1] / 255.0f;
|
||||
float b = img[idx + 2] / 255.0f;
|
||||
|
||||
// RGB to HSV
|
||||
float max_c = fmaxf(r, fmaxf(g, b));
|
||||
float min_c = fminf(r, fminf(g, b));
|
||||
float delta = max_c - min_c;
|
||||
|
||||
float h = 0.0f, s = 0.0f, v = max_c;
|
||||
|
||||
if (delta > 0.00001f) {
|
||||
s = delta / max_c;
|
||||
|
||||
if (max_c == r) {
|
||||
h = 60.0f * fmodf((g - b) / delta, 6.0f);
|
||||
} else if (max_c == g) {
|
||||
h = 60.0f * ((b - r) / delta + 2.0f);
|
||||
} else {
|
||||
h = 60.0f * ((r - g) / delta + 4.0f);
|
||||
}
|
||||
|
||||
if (h < 0) h += 360.0f;
|
||||
}
|
||||
|
||||
// Shift hue
|
||||
h = fmodf(h + shift, 360.0f);
|
||||
if (h < 0) h += 360.0f;
|
||||
|
||||
// HSV to RGB
|
||||
float c = v * s;
|
||||
float x_val = c * (1.0f - fabsf(fmodf(h / 60.0f, 2.0f) - 1.0f));
|
||||
float m = v - c;
|
||||
|
||||
float r_out, g_out, b_out;
|
||||
if (h < 60) {
|
||||
r_out = c; g_out = x_val; b_out = 0;
|
||||
} else if (h < 120) {
|
||||
r_out = x_val; g_out = c; b_out = 0;
|
||||
} else if (h < 180) {
|
||||
r_out = 0; g_out = c; b_out = x_val;
|
||||
} else if (h < 240) {
|
||||
r_out = 0; g_out = x_val; b_out = c;
|
||||
} else if (h < 300) {
|
||||
r_out = x_val; g_out = 0; b_out = c;
|
||||
} else {
|
||||
r_out = c; g_out = 0; b_out = x_val;
|
||||
}
|
||||
|
||||
img[idx] = (unsigned char)fminf(255.0f, (r_out + m) * 255.0f);
|
||||
img[idx + 1] = (unsigned char)fminf(255.0f, (g_out + m) * 255.0f);
|
||||
img[idx + 2] = (unsigned char)fminf(255.0f, (b_out + m) * 255.0f);
|
||||
}
|
||||
''', 'hue_shift')
|
||||
|
||||
|
||||
def prim_hue_shift(img, shift=0.0):
|
||||
"""Shift hue by degrees."""
|
||||
if img.ndim != 3 or img.shape[2] != 3:
|
||||
return img
|
||||
|
||||
if not GPU_AVAILABLE:
|
||||
import cv2
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV)
|
||||
hsv[:, :, 0] = (hsv[:, :, 0].astype(np.float32) + shift / 2) % 180
|
||||
return cv2.cvtColor(hsv, cv2.COLOR_HSV2RGB)
|
||||
|
||||
h, w = img.shape[:2]
|
||||
img_gpu = _to_gpu(img.astype(np.uint8)).copy()
|
||||
|
||||
block = (16, 16)
|
||||
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
|
||||
|
||||
_hue_shift_kernel(grid, block, (img_gpu, np.int32(w), np.int32(h), np.float32(shift)))
|
||||
|
||||
return _to_cpu(img_gpu)
|
||||
|
||||
|
||||
def prim_saturate(img, factor=1.0):
|
||||
"""Adjust saturation by factor."""
|
||||
if img.ndim != 3:
|
||||
return img
|
||||
|
||||
if not GPU_AVAILABLE:
|
||||
import cv2
|
||||
hsv = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
|
||||
hsv[:, :, 1] = np.clip(hsv[:, :, 1] * factor, 0, 255)
|
||||
return cv2.cvtColor(hsv.astype(np.uint8), cv2.COLOR_HSV2RGB)
|
||||
|
||||
# GPU version - simple desaturation blend
|
||||
img_gpu = _to_gpu(img.astype(np.float32))
|
||||
gray = 0.299 * img_gpu[:, :, 0] + 0.587 * img_gpu[:, :, 1] + 0.114 * img_gpu[:, :, 2]
|
||||
gray = gray[:, :, cp.newaxis]
|
||||
|
||||
if factor < 1.0:
|
||||
# Desaturate: blend toward gray
|
||||
result = img_gpu * factor + gray * (1 - factor)
|
||||
else:
|
||||
# Oversaturate: extrapolate away from gray
|
||||
result = gray + (img_gpu - gray) * factor
|
||||
|
||||
result = cp.clip(result, 0, 255).astype(cp.uint8)
|
||||
return _to_cpu(result)
|
||||
|
||||
|
||||
def prim_blend(img1, img2, alpha=0.5):
|
||||
"""Blend two images with alpha."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
|
||||
if GPU_AVAILABLE:
|
||||
img1_gpu = _to_gpu(img1.astype(np.float32))
|
||||
img2_gpu = _to_gpu(img2.astype(np.float32))
|
||||
result = img1_gpu * (1 - alpha) + img2_gpu * alpha
|
||||
result = xp.clip(result, 0, 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
|
||||
result = img1.astype(np.float32) * (1 - alpha) + img2.astype(np.float32) * alpha
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_add(img1, img2):
|
||||
"""Add two images (clamped)."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
if GPU_AVAILABLE:
|
||||
result = xp.clip(_to_gpu(img1).astype(np.int16) + _to_gpu(img2).astype(np.int16), 0, 255)
|
||||
return _to_cpu(result.astype(xp.uint8))
|
||||
return np.clip(img1.astype(np.int16) + img2.astype(np.int16), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_multiply(img1, img2):
|
||||
"""Multiply two images (normalized)."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
if GPU_AVAILABLE:
|
||||
result = (_to_gpu(img1).astype(np.float32) * _to_gpu(img2).astype(np.float32)) / 255.0
|
||||
result = xp.clip(result, 0, 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
result = (img1.astype(np.float32) * img2.astype(np.float32)) / 255.0
|
||||
return np.clip(result, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_screen(img1, img2):
|
||||
"""Screen blend mode."""
|
||||
xp = cp if GPU_AVAILABLE else np
|
||||
if GPU_AVAILABLE:
|
||||
i1 = _to_gpu(img1).astype(np.float32) / 255.0
|
||||
i2 = _to_gpu(img2).astype(np.float32) / 255.0
|
||||
result = 1.0 - (1.0 - i1) * (1.0 - i2)
|
||||
result = xp.clip(result * 255, 0, 255).astype(xp.uint8)
|
||||
return _to_cpu(result)
|
||||
i1 = img1.astype(np.float32) / 255.0
|
||||
i2 = img2.astype(np.float32) / 255.0
|
||||
result = 1.0 - (1.0 - i1) * (1.0 - i2)
|
||||
return np.clip(result * 255, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
# Import CPU primitives as fallbacks
|
||||
def _get_cpu_primitives():
|
||||
"""Get all primitives from CPU color_ops module as fallbacks."""
|
||||
from sexp_effects.primitive_libs import color_ops
|
||||
return color_ops.PRIMITIVES
|
||||
|
||||
|
||||
# Export functions - start with CPU primitives, then override with GPU versions
|
||||
PRIMITIVES = _get_cpu_primitives().copy()
|
||||
|
||||
# Override specific primitives with GPU-accelerated versions
|
||||
PRIMITIVES.update({
|
||||
'invert': prim_invert,
|
||||
'grayscale': prim_grayscale,
|
||||
'brightness': prim_brightness,
|
||||
'contrast': prim_contrast,
|
||||
'hue-shift': prim_hue_shift,
|
||||
'saturate': prim_saturate,
|
||||
'blend': prim_blend,
|
||||
'add': prim_add,
|
||||
'multiply': prim_multiply,
|
||||
'screen': prim_screen,
|
||||
})
|
||||
294
l1/sexp_effects/primitive_libs/core.py
Normal file
294
l1/sexp_effects/primitive_libs/core.py
Normal file
@@ -0,0 +1,294 @@
|
||||
"""
|
||||
Core Primitives - Always available, minimal essential set.
|
||||
|
||||
These are the primitives that form the foundation of the language.
|
||||
They cannot be overridden by libraries.
|
||||
"""
|
||||
|
||||
|
||||
# Arithmetic
|
||||
def prim_add(*args):
|
||||
if len(args) == 0:
|
||||
return 0
|
||||
result = args[0]
|
||||
for arg in args[1:]:
|
||||
result = result + arg
|
||||
return result
|
||||
|
||||
|
||||
def prim_sub(a, b=None):
|
||||
if b is None:
|
||||
return -a
|
||||
return a - b
|
||||
|
||||
|
||||
def prim_mul(*args):
|
||||
if len(args) == 0:
|
||||
return 1
|
||||
result = args[0]
|
||||
for arg in args[1:]:
|
||||
result = result * arg
|
||||
return result
|
||||
|
||||
|
||||
def prim_div(a, b):
|
||||
return a / b
|
||||
|
||||
|
||||
def prim_mod(a, b):
|
||||
return a % b
|
||||
|
||||
|
||||
def prim_abs(x):
|
||||
return abs(x)
|
||||
|
||||
|
||||
def prim_min(*args):
|
||||
return min(args)
|
||||
|
||||
|
||||
def prim_max(*args):
|
||||
return max(args)
|
||||
|
||||
|
||||
def prim_round(x):
|
||||
import numpy as np
|
||||
if hasattr(x, '_data'): # Xector
|
||||
from .xector import Xector
|
||||
return Xector(np.round(x._data), x._shape)
|
||||
if isinstance(x, np.ndarray):
|
||||
return np.round(x)
|
||||
return round(x)
|
||||
|
||||
|
||||
def prim_floor(x):
|
||||
import numpy as np
|
||||
if hasattr(x, '_data'): # Xector
|
||||
from .xector import Xector
|
||||
return Xector(np.floor(x._data), x._shape)
|
||||
if isinstance(x, np.ndarray):
|
||||
return np.floor(x)
|
||||
import math
|
||||
return math.floor(x)
|
||||
|
||||
|
||||
def prim_ceil(x):
|
||||
import numpy as np
|
||||
if hasattr(x, '_data'): # Xector
|
||||
from .xector import Xector
|
||||
return Xector(np.ceil(x._data), x._shape)
|
||||
if isinstance(x, np.ndarray):
|
||||
return np.ceil(x)
|
||||
import math
|
||||
return math.ceil(x)
|
||||
|
||||
|
||||
# Comparison
|
||||
def prim_lt(a, b):
|
||||
return a < b
|
||||
|
||||
|
||||
def prim_gt(a, b):
|
||||
return a > b
|
||||
|
||||
|
||||
def prim_le(a, b):
|
||||
return a <= b
|
||||
|
||||
|
||||
def prim_ge(a, b):
|
||||
return a >= b
|
||||
|
||||
|
||||
def prim_eq(a, b):
|
||||
if isinstance(a, float) or isinstance(b, float):
|
||||
return abs(a - b) < 1e-9
|
||||
return a == b
|
||||
|
||||
|
||||
def prim_ne(a, b):
|
||||
return not prim_eq(a, b)
|
||||
|
||||
|
||||
# Logic
|
||||
def prim_not(x):
|
||||
return not x
|
||||
|
||||
|
||||
def prim_and(*args):
|
||||
for a in args:
|
||||
if not a:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def prim_or(*args):
|
||||
for a in args:
|
||||
if a:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
# Basic data access
|
||||
def prim_get(obj, key, default=None):
|
||||
"""Get value from dict or list."""
|
||||
if isinstance(obj, dict):
|
||||
return obj.get(key, default)
|
||||
elif isinstance(obj, (list, tuple)):
|
||||
try:
|
||||
return obj[int(key)]
|
||||
except (IndexError, ValueError):
|
||||
return default
|
||||
return default
|
||||
|
||||
|
||||
def prim_nth(seq, i):
|
||||
i = int(i)
|
||||
if 0 <= i < len(seq):
|
||||
return seq[i]
|
||||
return None
|
||||
|
||||
|
||||
def prim_first(seq):
|
||||
return seq[0] if seq else None
|
||||
|
||||
|
||||
def prim_length(seq):
|
||||
return len(seq)
|
||||
|
||||
|
||||
def prim_list(*args):
|
||||
return list(args)
|
||||
|
||||
|
||||
# Type checking
|
||||
def prim_is_number(x):
|
||||
return isinstance(x, (int, float))
|
||||
|
||||
|
||||
def prim_is_string(x):
|
||||
return isinstance(x, str)
|
||||
|
||||
|
||||
def prim_is_list(x):
|
||||
return isinstance(x, (list, tuple))
|
||||
|
||||
|
||||
def prim_is_dict(x):
|
||||
return isinstance(x, dict)
|
||||
|
||||
|
||||
def prim_is_nil(x):
|
||||
return x is None
|
||||
|
||||
|
||||
# Higher-order / iteration
|
||||
def prim_reduce(seq, init, fn):
|
||||
"""(reduce seq init fn) — fold left: fn(fn(fn(init, s0), s1), s2) ..."""
|
||||
acc = init
|
||||
for item in seq:
|
||||
acc = fn(acc, item)
|
||||
return acc
|
||||
|
||||
|
||||
def prim_map(seq, fn):
|
||||
"""(map seq fn) — apply fn to each element, return new list."""
|
||||
return [fn(item) for item in seq]
|
||||
|
||||
|
||||
def prim_range(*args):
|
||||
"""(range end), (range start end), or (range start end step) — integer range."""
|
||||
if len(args) == 1:
|
||||
return list(range(int(args[0])))
|
||||
elif len(args) == 2:
|
||||
return list(range(int(args[0]), int(args[1])))
|
||||
elif len(args) >= 3:
|
||||
return list(range(int(args[0]), int(args[1]), int(args[2])))
|
||||
return []
|
||||
|
||||
|
||||
# Random
|
||||
import random
|
||||
_rng = random.Random()
|
||||
|
||||
def set_random_seed(seed):
|
||||
"""Set the random seed for deterministic output."""
|
||||
global _rng
|
||||
_rng = random.Random(seed)
|
||||
|
||||
def prim_rand():
|
||||
"""Return random float in [0, 1)."""
|
||||
return _rng.random()
|
||||
|
||||
def prim_rand_int(lo, hi):
|
||||
"""Return random integer in [lo, hi]."""
|
||||
return _rng.randint(int(lo), int(hi))
|
||||
|
||||
def prim_rand_range(lo, hi):
|
||||
"""Return random float in [lo, hi)."""
|
||||
return lo + _rng.random() * (hi - lo)
|
||||
|
||||
def prim_map_range(val, from_lo, from_hi, to_lo, to_hi):
|
||||
"""Map value from one range to another."""
|
||||
if from_hi == from_lo:
|
||||
return to_lo
|
||||
t = (val - from_lo) / (from_hi - from_lo)
|
||||
return to_lo + t * (to_hi - to_lo)
|
||||
|
||||
|
||||
# Core primitives dict
|
||||
PRIMITIVES = {
|
||||
# Arithmetic
|
||||
'+': prim_add,
|
||||
'-': prim_sub,
|
||||
'*': prim_mul,
|
||||
'/': prim_div,
|
||||
'mod': prim_mod,
|
||||
'abs': prim_abs,
|
||||
'min': prim_min,
|
||||
'max': prim_max,
|
||||
'round': prim_round,
|
||||
'floor': prim_floor,
|
||||
'ceil': prim_ceil,
|
||||
|
||||
# Comparison
|
||||
'<': prim_lt,
|
||||
'>': prim_gt,
|
||||
'<=': prim_le,
|
||||
'>=': prim_ge,
|
||||
'=': prim_eq,
|
||||
'!=': prim_ne,
|
||||
|
||||
# Logic
|
||||
'not': prim_not,
|
||||
'and': prim_and,
|
||||
'or': prim_or,
|
||||
|
||||
# Data access
|
||||
'get': prim_get,
|
||||
'nth': prim_nth,
|
||||
'first': prim_first,
|
||||
'length': prim_length,
|
||||
'len': prim_length,
|
||||
'list': prim_list,
|
||||
|
||||
# Type predicates
|
||||
'number?': prim_is_number,
|
||||
'string?': prim_is_string,
|
||||
'list?': prim_is_list,
|
||||
'dict?': prim_is_dict,
|
||||
'nil?': prim_is_nil,
|
||||
'is-nil': prim_is_nil,
|
||||
|
||||
# Higher-order / iteration
|
||||
'reduce': prim_reduce,
|
||||
'fold': prim_reduce,
|
||||
'map': prim_map,
|
||||
'range': prim_range,
|
||||
|
||||
# Random
|
||||
'rand': prim_rand,
|
||||
'rand-int': prim_rand_int,
|
||||
'rand-range': prim_rand_range,
|
||||
'map-range': prim_map_range,
|
||||
}
|
||||
690
l1/sexp_effects/primitive_libs/drawing.py
Normal file
690
l1/sexp_effects/primitive_libs/drawing.py
Normal file
@@ -0,0 +1,690 @@
|
||||
"""
|
||||
Drawing Primitives Library
|
||||
|
||||
Draw shapes, text, and characters on images with sophisticated text handling.
|
||||
|
||||
Text Features:
|
||||
- Font loading from files or system fonts
|
||||
- Text measurement and fitting
|
||||
- Alignment (left/center/right, top/middle/bottom)
|
||||
- Opacity for fade effects
|
||||
- Multi-line text support
|
||||
- Shadow and outline effects
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
import os
|
||||
import glob as glob_module
|
||||
from typing import Optional, Tuple, List, Union
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Font Management
|
||||
# =============================================================================
|
||||
|
||||
# Font cache: (path, size) -> font object
|
||||
_font_cache = {}
|
||||
|
||||
# Common system font directories
|
||||
FONT_DIRS = [
|
||||
"/usr/share/fonts",
|
||||
"/usr/local/share/fonts",
|
||||
"~/.fonts",
|
||||
"~/.local/share/fonts",
|
||||
"/System/Library/Fonts", # macOS
|
||||
"/Library/Fonts", # macOS
|
||||
"C:/Windows/Fonts", # Windows
|
||||
]
|
||||
|
||||
# Default fonts to try (in order of preference)
|
||||
DEFAULT_FONTS = [
|
||||
"DejaVuSans.ttf",
|
||||
"DejaVuSansMono.ttf",
|
||||
"Arial.ttf",
|
||||
"Helvetica.ttf",
|
||||
"FreeSans.ttf",
|
||||
"LiberationSans-Regular.ttf",
|
||||
]
|
||||
|
||||
|
||||
def _find_font_file(name: str) -> Optional[str]:
|
||||
"""Find a font file by name in system directories."""
|
||||
# If it's already a full path
|
||||
if os.path.isfile(name):
|
||||
return name
|
||||
|
||||
# Expand user paths
|
||||
expanded = os.path.expanduser(name)
|
||||
if os.path.isfile(expanded):
|
||||
return expanded
|
||||
|
||||
# Search in font directories
|
||||
for font_dir in FONT_DIRS:
|
||||
font_dir = os.path.expanduser(font_dir)
|
||||
if not os.path.isdir(font_dir):
|
||||
continue
|
||||
|
||||
# Direct match
|
||||
direct = os.path.join(font_dir, name)
|
||||
if os.path.isfile(direct):
|
||||
return direct
|
||||
|
||||
# Recursive search
|
||||
for root, dirs, files in os.walk(font_dir):
|
||||
for f in files:
|
||||
if f.lower() == name.lower():
|
||||
return os.path.join(root, f)
|
||||
# Also match without extension
|
||||
base = os.path.splitext(f)[0]
|
||||
if base.lower() == name.lower():
|
||||
return os.path.join(root, f)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_default_font(size: int = 24) -> ImageFont.FreeTypeFont:
|
||||
"""Get a default font at the given size."""
|
||||
for font_name in DEFAULT_FONTS:
|
||||
path = _find_font_file(font_name)
|
||||
if path:
|
||||
try:
|
||||
return ImageFont.truetype(path, size)
|
||||
except:
|
||||
continue
|
||||
|
||||
# Last resort: PIL default
|
||||
return ImageFont.load_default()
|
||||
|
||||
|
||||
def prim_make_font(name_or_path: str, size: int = 24) -> ImageFont.FreeTypeFont:
|
||||
"""
|
||||
Load a font by name or path.
|
||||
|
||||
(make-font "Arial" 32) ; system font by name
|
||||
(make-font "/path/to/font.ttf" 24) ; font file path
|
||||
(make-font "DejaVuSans" 48) ; searches common locations
|
||||
|
||||
Returns a font object for use with text primitives.
|
||||
"""
|
||||
size = int(size)
|
||||
|
||||
# Check cache
|
||||
cache_key = (name_or_path, size)
|
||||
if cache_key in _font_cache:
|
||||
return _font_cache[cache_key]
|
||||
|
||||
# Find the font file
|
||||
path = _find_font_file(name_or_path)
|
||||
if not path:
|
||||
raise FileNotFoundError(f"Font not found: {name_or_path}")
|
||||
|
||||
# Load and cache
|
||||
font = ImageFont.truetype(path, size)
|
||||
_font_cache[cache_key] = font
|
||||
return font
|
||||
|
||||
|
||||
def prim_list_fonts() -> List[str]:
|
||||
"""
|
||||
List available system fonts.
|
||||
|
||||
(list-fonts) ; -> ("Arial.ttf" "DejaVuSans.ttf" ...)
|
||||
|
||||
Returns list of font filenames found in system directories.
|
||||
"""
|
||||
fonts = set()
|
||||
|
||||
for font_dir in FONT_DIRS:
|
||||
font_dir = os.path.expanduser(font_dir)
|
||||
if not os.path.isdir(font_dir):
|
||||
continue
|
||||
|
||||
for root, dirs, files in os.walk(font_dir):
|
||||
for f in files:
|
||||
if f.lower().endswith(('.ttf', '.otf', '.ttc')):
|
||||
fonts.add(f)
|
||||
|
||||
return sorted(fonts)
|
||||
|
||||
|
||||
def prim_font_size(font: ImageFont.FreeTypeFont) -> int:
|
||||
"""
|
||||
Get the size of a font.
|
||||
|
||||
(font-size my-font) ; -> 24
|
||||
"""
|
||||
return font.size
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Measurement
|
||||
# =============================================================================
|
||||
|
||||
def prim_text_size(text: str, font=None, font_size: int = 24) -> Tuple[int, int]:
|
||||
"""
|
||||
Measure text dimensions.
|
||||
|
||||
(text-size "Hello" my-font) ; -> (width height)
|
||||
(text-size "Hello" :font-size 32) ; -> (width height) with default font
|
||||
|
||||
For multi-line text, returns total bounding box.
|
||||
"""
|
||||
if font is None:
|
||||
font = _get_default_font(int(font_size))
|
||||
elif isinstance(font, (int, float)):
|
||||
font = _get_default_font(int(font))
|
||||
|
||||
# Create temporary image for measurement
|
||||
img = Image.new('RGB', (1, 1))
|
||||
draw = ImageDraw.Draw(img)
|
||||
|
||||
bbox = draw.textbbox((0, 0), str(text), font=font)
|
||||
width = bbox[2] - bbox[0]
|
||||
height = bbox[3] - bbox[1]
|
||||
|
||||
return (width, height)
|
||||
|
||||
|
||||
def prim_text_metrics(font=None, font_size: int = 24) -> dict:
|
||||
"""
|
||||
Get font metrics.
|
||||
|
||||
(text-metrics my-font) ; -> {ascent: 20, descent: 5, height: 25}
|
||||
|
||||
Useful for precise text layout.
|
||||
"""
|
||||
if font is None:
|
||||
font = _get_default_font(int(font_size))
|
||||
elif isinstance(font, (int, float)):
|
||||
font = _get_default_font(int(font))
|
||||
|
||||
ascent, descent = font.getmetrics()
|
||||
return {
|
||||
'ascent': ascent,
|
||||
'descent': descent,
|
||||
'height': ascent + descent,
|
||||
'size': font.size,
|
||||
}
|
||||
|
||||
|
||||
def prim_fit_text_size(text: str, max_width: int, max_height: int,
|
||||
font_name: str = None, min_size: int = 8,
|
||||
max_size: int = 500) -> int:
|
||||
"""
|
||||
Calculate font size to fit text within bounds.
|
||||
|
||||
(fit-text-size "Hello World" 400 100) ; -> 48
|
||||
(fit-text-size "Title" 800 200 :font-name "Arial")
|
||||
|
||||
Returns the largest font size that fits within max_width x max_height.
|
||||
"""
|
||||
max_width = int(max_width)
|
||||
max_height = int(max_height)
|
||||
min_size = int(min_size)
|
||||
max_size = int(max_size)
|
||||
text = str(text)
|
||||
|
||||
# Binary search for optimal size
|
||||
best_size = min_size
|
||||
low, high = min_size, max_size
|
||||
|
||||
while low <= high:
|
||||
mid = (low + high) // 2
|
||||
|
||||
if font_name:
|
||||
try:
|
||||
font = prim_make_font(font_name, mid)
|
||||
except:
|
||||
font = _get_default_font(mid)
|
||||
else:
|
||||
font = _get_default_font(mid)
|
||||
|
||||
w, h = prim_text_size(text, font)
|
||||
|
||||
if w <= max_width and h <= max_height:
|
||||
best_size = mid
|
||||
low = mid + 1
|
||||
else:
|
||||
high = mid - 1
|
||||
|
||||
return best_size
|
||||
|
||||
|
||||
def prim_fit_font(text: str, max_width: int, max_height: int,
|
||||
font_name: str = None, min_size: int = 8,
|
||||
max_size: int = 500) -> ImageFont.FreeTypeFont:
|
||||
"""
|
||||
Create a font sized to fit text within bounds.
|
||||
|
||||
(fit-font "Hello World" 400 100) ; -> font object
|
||||
(fit-font "Title" 800 200 :font-name "Arial")
|
||||
|
||||
Returns a font object at the optimal size.
|
||||
"""
|
||||
size = prim_fit_text_size(text, max_width, max_height,
|
||||
font_name, min_size, max_size)
|
||||
|
||||
if font_name:
|
||||
try:
|
||||
return prim_make_font(font_name, size)
|
||||
except:
|
||||
pass
|
||||
|
||||
return _get_default_font(size)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Text Drawing
|
||||
# =============================================================================
|
||||
|
||||
def prim_text(img: np.ndarray, text: str,
|
||||
x: int = None, y: int = None,
|
||||
width: int = None, height: int = None,
|
||||
font=None, font_size: int = 24, font_name: str = None,
|
||||
color=None, opacity: float = 1.0,
|
||||
align: str = "left", valign: str = "top",
|
||||
fit: bool = False,
|
||||
shadow: bool = False, shadow_color=None, shadow_offset: int = 2,
|
||||
outline: bool = False, outline_color=None, outline_width: int = 1,
|
||||
line_spacing: float = 1.2) -> np.ndarray:
|
||||
"""
|
||||
Draw text with alignment, opacity, and effects.
|
||||
|
||||
Basic usage:
|
||||
(text frame "Hello" :x 100 :y 50)
|
||||
|
||||
Centered in frame:
|
||||
(text frame "Title" :align "center" :valign "middle")
|
||||
|
||||
Fit to box:
|
||||
(text frame "Big Text" :x 50 :y 50 :width 400 :height 100 :fit true)
|
||||
|
||||
With fade (for animations):
|
||||
(text frame "Fading" :x 100 :y 100 :opacity 0.5)
|
||||
|
||||
With effects:
|
||||
(text frame "Shadow" :x 100 :y 100 :shadow true)
|
||||
(text frame "Outline" :x 100 :y 100 :outline true :outline-color (0 0 0))
|
||||
|
||||
Args:
|
||||
img: Input frame
|
||||
text: Text to draw
|
||||
x, y: Position (if not specified, uses alignment in full frame)
|
||||
width, height: Bounding box (for fit and alignment within box)
|
||||
font: Font object from make-font
|
||||
font_size: Size if no font specified
|
||||
font_name: Font name to load
|
||||
color: RGB tuple (default white)
|
||||
opacity: 0.0 (invisible) to 1.0 (opaque) for fading
|
||||
align: "left", "center", "right"
|
||||
valign: "top", "middle", "bottom"
|
||||
fit: If true, auto-size font to fit in box
|
||||
shadow: Draw drop shadow
|
||||
shadow_color: Shadow color (default black)
|
||||
shadow_offset: Shadow offset in pixels
|
||||
outline: Draw text outline
|
||||
outline_color: Outline color (default black)
|
||||
outline_width: Outline thickness
|
||||
line_spacing: Multiplier for line height (for multi-line)
|
||||
|
||||
Returns:
|
||||
Frame with text drawn
|
||||
"""
|
||||
h, w = img.shape[:2]
|
||||
text = str(text)
|
||||
|
||||
# Default colors
|
||||
if color is None:
|
||||
color = (255, 255, 255)
|
||||
else:
|
||||
color = tuple(int(c) for c in color)
|
||||
|
||||
if shadow_color is None:
|
||||
shadow_color = (0, 0, 0)
|
||||
else:
|
||||
shadow_color = tuple(int(c) for c in shadow_color)
|
||||
|
||||
if outline_color is None:
|
||||
outline_color = (0, 0, 0)
|
||||
else:
|
||||
outline_color = tuple(int(c) for c in outline_color)
|
||||
|
||||
# Determine bounding box
|
||||
if x is None:
|
||||
x = 0
|
||||
if width is None:
|
||||
width = w
|
||||
if y is None:
|
||||
y = 0
|
||||
if height is None:
|
||||
height = h
|
||||
|
||||
x, y = int(x), int(y)
|
||||
box_width = int(width) if width else w - x
|
||||
box_height = int(height) if height else h - y
|
||||
|
||||
# Get or create font
|
||||
if font is None:
|
||||
if fit:
|
||||
font = prim_fit_font(text, box_width, box_height, font_name)
|
||||
elif font_name:
|
||||
try:
|
||||
font = prim_make_font(font_name, int(font_size))
|
||||
except:
|
||||
font = _get_default_font(int(font_size))
|
||||
else:
|
||||
font = _get_default_font(int(font_size))
|
||||
|
||||
# Measure text
|
||||
text_w, text_h = prim_text_size(text, font)
|
||||
|
||||
# Calculate position based on alignment
|
||||
if align == "center":
|
||||
draw_x = x + (box_width - text_w) // 2
|
||||
elif align == "right":
|
||||
draw_x = x + box_width - text_w
|
||||
else: # left
|
||||
draw_x = x
|
||||
|
||||
if valign == "middle":
|
||||
draw_y = y + (box_height - text_h) // 2
|
||||
elif valign == "bottom":
|
||||
draw_y = y + box_height - text_h
|
||||
else: # top
|
||||
draw_y = y
|
||||
|
||||
# Create RGBA image for compositing with opacity
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
|
||||
# Create text layer with transparency
|
||||
text_layer = Image.new('RGBA', (w, h), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(text_layer)
|
||||
|
||||
# Draw shadow first (if enabled)
|
||||
if shadow:
|
||||
shadow_x = draw_x + shadow_offset
|
||||
shadow_y = draw_y + shadow_offset
|
||||
shadow_rgba = shadow_color + (int(255 * opacity * 0.5),)
|
||||
draw.text((shadow_x, shadow_y), text, fill=shadow_rgba, font=font)
|
||||
|
||||
# Draw outline (if enabled)
|
||||
if outline:
|
||||
outline_rgba = outline_color + (int(255 * opacity),)
|
||||
ow = int(outline_width)
|
||||
for dx in range(-ow, ow + 1):
|
||||
for dy in range(-ow, ow + 1):
|
||||
if dx != 0 or dy != 0:
|
||||
draw.text((draw_x + dx, draw_y + dy), text,
|
||||
fill=outline_rgba, font=font)
|
||||
|
||||
# Draw main text
|
||||
text_rgba = color + (int(255 * opacity),)
|
||||
draw.text((draw_x, draw_y), text, fill=text_rgba, font=font)
|
||||
|
||||
# Composite
|
||||
result = Image.alpha_composite(pil_img, text_layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_text_box(img: np.ndarray, text: str,
|
||||
x: int, y: int, width: int, height: int,
|
||||
font=None, font_size: int = 24, font_name: str = None,
|
||||
color=None, opacity: float = 1.0,
|
||||
align: str = "center", valign: str = "middle",
|
||||
fit: bool = True,
|
||||
padding: int = 0,
|
||||
background=None, background_opacity: float = 0.5,
|
||||
**kwargs) -> np.ndarray:
|
||||
"""
|
||||
Draw text fitted within a box, optionally with background.
|
||||
|
||||
(text-box frame "Title" 50 50 400 100)
|
||||
(text-box frame "Subtitle" 50 160 400 50
|
||||
:background (0 0 0) :background-opacity 0.7)
|
||||
|
||||
Convenience wrapper around text() for common box-with-text pattern.
|
||||
"""
|
||||
x, y = int(x), int(y)
|
||||
width, height = int(width), int(height)
|
||||
padding = int(padding)
|
||||
|
||||
result = img.copy()
|
||||
|
||||
# Draw background if specified
|
||||
if background is not None:
|
||||
bg_color = tuple(int(c) for c in background)
|
||||
|
||||
# Create background with opacity
|
||||
pil_img = Image.fromarray(result).convert('RGBA')
|
||||
bg_layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
bg_draw = ImageDraw.Draw(bg_layer)
|
||||
bg_rgba = bg_color + (int(255 * background_opacity),)
|
||||
bg_draw.rectangle([x, y, x + width, y + height], fill=bg_rgba)
|
||||
result = np.array(Image.alpha_composite(pil_img, bg_layer).convert('RGB'))
|
||||
|
||||
# Draw text within padded box
|
||||
return prim_text(result, text,
|
||||
x=x + padding, y=y + padding,
|
||||
width=width - 2 * padding, height=height - 2 * padding,
|
||||
font=font, font_size=font_size, font_name=font_name,
|
||||
color=color, opacity=opacity,
|
||||
align=align, valign=valign, fit=fit,
|
||||
**kwargs)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Legacy text functions (keep for compatibility)
|
||||
# =============================================================================
|
||||
|
||||
def prim_draw_char(img, char, x, y, font_size=16, color=None):
|
||||
"""Draw a single character at (x, y). Legacy function."""
|
||||
return prim_text(img, str(char), x=int(x), y=int(y),
|
||||
font_size=int(font_size), color=color)
|
||||
|
||||
|
||||
def prim_draw_text(img, text, x, y, font_size=16, color=None):
|
||||
"""Draw text string at (x, y). Legacy function."""
|
||||
return prim_text(img, str(text), x=int(x), y=int(y),
|
||||
font_size=int(font_size), color=color)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Shape Drawing
|
||||
# =============================================================================
|
||||
|
||||
def prim_fill_rect(img, x, y, w, h, color=None, opacity: float = 1.0):
|
||||
"""
|
||||
Fill a rectangle with color.
|
||||
|
||||
(fill-rect frame 10 10 100 50 (255 0 0))
|
||||
(fill-rect frame 10 10 100 50 (255 0 0) :opacity 0.5)
|
||||
"""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
result[y:y+h, x:x+w] = color
|
||||
return result
|
||||
|
||||
# With opacity, use alpha compositing
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
fill_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
draw.rectangle([x, y, x + w, y + h], fill=fill_rgba)
|
||||
result = Image.alpha_composite(pil_img, layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_draw_rect(img, x, y, w, h, color=None, thickness=1, opacity: float = 1.0):
|
||||
"""Draw rectangle outline."""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
cv2.rectangle(result, (int(x), int(y)), (int(x+w), int(y+h)),
|
||||
tuple(int(c) for c in color), int(thickness))
|
||||
return result
|
||||
|
||||
# With opacity
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
outline_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
draw.rectangle([int(x), int(y), int(x+w), int(y+h)],
|
||||
outline=outline_rgba, width=int(thickness))
|
||||
result = Image.alpha_composite(pil_img, layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_draw_line(img, x1, y1, x2, y2, color=None, thickness=1, opacity: float = 1.0):
|
||||
"""Draw a line from (x1, y1) to (x2, y2)."""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
cv2.line(result, (int(x1), int(y1)), (int(x2), int(y2)),
|
||||
tuple(int(c) for c in color), int(thickness))
|
||||
return result
|
||||
|
||||
# With opacity
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
line_rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
draw.line([(int(x1), int(y1)), (int(x2), int(y2))],
|
||||
fill=line_rgba, width=int(thickness))
|
||||
result = Image.alpha_composite(pil_img, layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_draw_circle(img, cx, cy, radius, color=None, thickness=1,
|
||||
fill=False, opacity: float = 1.0):
|
||||
"""Draw a circle."""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
t = -1 if fill else int(thickness)
|
||||
cv2.circle(result, (int(cx), int(cy)), int(radius),
|
||||
tuple(int(c) for c in color), t)
|
||||
return result
|
||||
|
||||
# With opacity
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
cx, cy, r = int(cx), int(cy), int(radius)
|
||||
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
|
||||
if fill:
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r], fill=rgba)
|
||||
else:
|
||||
draw.ellipse([cx - r, cy - r, cx + r, cy + r],
|
||||
outline=rgba, width=int(thickness))
|
||||
|
||||
result = Image.alpha_composite(pil_img, layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_draw_ellipse(img, cx, cy, rx, ry, angle=0, color=None,
|
||||
thickness=1, fill=False, opacity: float = 1.0):
|
||||
"""Draw an ellipse."""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
t = -1 if fill else int(thickness)
|
||||
cv2.ellipse(result, (int(cx), int(cy)), (int(rx), int(ry)),
|
||||
float(angle), 0, 360, tuple(int(c) for c in color), t)
|
||||
return result
|
||||
|
||||
# With opacity (note: PIL doesn't support rotated ellipses easily)
|
||||
# Fall back to cv2 on a separate layer
|
||||
layer = np.zeros((img.shape[0], img.shape[1], 4), dtype=np.uint8)
|
||||
t = -1 if fill else int(thickness)
|
||||
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
cv2.ellipse(layer, (int(cx), int(cy)), (int(rx), int(ry)),
|
||||
float(angle), 0, 360, rgba, t)
|
||||
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
pil_layer = Image.fromarray(layer)
|
||||
result = Image.alpha_composite(pil_img, pil_layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
def prim_draw_polygon(img, points, color=None, thickness=1,
|
||||
fill=False, opacity: float = 1.0):
|
||||
"""Draw a polygon from list of [x, y] points."""
|
||||
if color is None:
|
||||
color = [255, 255, 255]
|
||||
|
||||
if opacity >= 1.0:
|
||||
result = img.copy()
|
||||
pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2))
|
||||
if fill:
|
||||
cv2.fillPoly(result, [pts], tuple(int(c) for c in color))
|
||||
else:
|
||||
cv2.polylines(result, [pts], True,
|
||||
tuple(int(c) for c in color), int(thickness))
|
||||
return result
|
||||
|
||||
# With opacity
|
||||
pil_img = Image.fromarray(img).convert('RGBA')
|
||||
layer = Image.new('RGBA', (pil_img.width, pil_img.height), (0, 0, 0, 0))
|
||||
draw = ImageDraw.Draw(layer)
|
||||
|
||||
pts_flat = [(int(p[0]), int(p[1])) for p in points]
|
||||
rgba = tuple(int(c) for c in color) + (int(255 * opacity),)
|
||||
|
||||
if fill:
|
||||
draw.polygon(pts_flat, fill=rgba)
|
||||
else:
|
||||
draw.polygon(pts_flat, outline=rgba, width=int(thickness))
|
||||
|
||||
result = Image.alpha_composite(pil_img, layer)
|
||||
return np.array(result.convert('RGB'))
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# PRIMITIVES Export
|
||||
# =============================================================================
|
||||
|
||||
PRIMITIVES = {
|
||||
# Font management
|
||||
'make-font': prim_make_font,
|
||||
'list-fonts': prim_list_fonts,
|
||||
'font-size': prim_font_size,
|
||||
|
||||
# Text measurement
|
||||
'text-size': prim_text_size,
|
||||
'text-metrics': prim_text_metrics,
|
||||
'fit-text-size': prim_fit_text_size,
|
||||
'fit-font': prim_fit_font,
|
||||
|
||||
# Text drawing
|
||||
'text': prim_text,
|
||||
'text-box': prim_text_box,
|
||||
|
||||
# Legacy text (compatibility)
|
||||
'draw-char': prim_draw_char,
|
||||
'draw-text': prim_draw_text,
|
||||
|
||||
# Rectangles
|
||||
'fill-rect': prim_fill_rect,
|
||||
'draw-rect': prim_draw_rect,
|
||||
|
||||
# Lines and shapes
|
||||
'draw-line': prim_draw_line,
|
||||
'draw-circle': prim_draw_circle,
|
||||
'draw-ellipse': prim_draw_ellipse,
|
||||
'draw-polygon': prim_draw_polygon,
|
||||
}
|
||||
119
l1/sexp_effects/primitive_libs/filters.py
Normal file
119
l1/sexp_effects/primitive_libs/filters.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""
|
||||
Filters Primitives Library
|
||||
|
||||
Image filters: blur, sharpen, edges, convolution.
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
|
||||
def prim_blur(img, radius):
|
||||
"""Gaussian blur with given radius."""
|
||||
radius = max(1, int(radius))
|
||||
ksize = radius * 2 + 1
|
||||
return cv2.GaussianBlur(img, (ksize, ksize), 0)
|
||||
|
||||
|
||||
def prim_box_blur(img, radius):
|
||||
"""Box blur with given radius."""
|
||||
radius = max(1, int(radius))
|
||||
ksize = radius * 2 + 1
|
||||
return cv2.blur(img, (ksize, ksize))
|
||||
|
||||
|
||||
def prim_median_blur(img, radius):
|
||||
"""Median blur (good for noise removal)."""
|
||||
radius = max(1, int(radius))
|
||||
ksize = radius * 2 + 1
|
||||
return cv2.medianBlur(img, ksize)
|
||||
|
||||
|
||||
def prim_bilateral(img, d=9, sigma_color=75, sigma_space=75):
|
||||
"""Bilateral filter (edge-preserving blur)."""
|
||||
return cv2.bilateralFilter(img, d, sigma_color, sigma_space)
|
||||
|
||||
|
||||
def prim_sharpen(img, amount=1.0):
|
||||
"""Sharpen image using unsharp mask."""
|
||||
blurred = cv2.GaussianBlur(img, (0, 0), 3)
|
||||
return cv2.addWeighted(img, 1.0 + amount, blurred, -amount, 0)
|
||||
|
||||
|
||||
def prim_edges(img, low=50, high=150):
|
||||
"""Canny edge detection."""
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
edges = cv2.Canny(gray, low, high)
|
||||
return cv2.cvtColor(edges, cv2.COLOR_GRAY2RGB)
|
||||
|
||||
|
||||
def prim_sobel(img, ksize=3):
|
||||
"""Sobel edge detection."""
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
sobelx = cv2.Sobel(gray, cv2.CV_64F, 1, 0, ksize=ksize)
|
||||
sobely = cv2.Sobel(gray, cv2.CV_64F, 0, 1, ksize=ksize)
|
||||
mag = np.sqrt(sobelx**2 + sobely**2)
|
||||
mag = np.clip(mag, 0, 255).astype(np.uint8)
|
||||
return cv2.cvtColor(mag, cv2.COLOR_GRAY2RGB)
|
||||
|
||||
|
||||
def prim_laplacian(img, ksize=3):
|
||||
"""Laplacian edge detection."""
|
||||
gray = cv2.cvtColor(img, cv2.COLOR_RGB2GRAY)
|
||||
lap = cv2.Laplacian(gray, cv2.CV_64F, ksize=ksize)
|
||||
lap = np.abs(lap)
|
||||
lap = np.clip(lap, 0, 255).astype(np.uint8)
|
||||
return cv2.cvtColor(lap, cv2.COLOR_GRAY2RGB)
|
||||
|
||||
|
||||
def prim_emboss(img):
|
||||
"""Emboss effect."""
|
||||
kernel = np.array([[-2, -1, 0],
|
||||
[-1, 1, 1],
|
||||
[ 0, 1, 2]])
|
||||
result = cv2.filter2D(img, -1, kernel)
|
||||
return np.clip(result + 128, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_dilate(img, size=1):
|
||||
"""Morphological dilation."""
|
||||
kernel = np.ones((size * 2 + 1, size * 2 + 1), np.uint8)
|
||||
return cv2.dilate(img, kernel)
|
||||
|
||||
|
||||
def prim_erode(img, size=1):
|
||||
"""Morphological erosion."""
|
||||
kernel = np.ones((size * 2 + 1, size * 2 + 1), np.uint8)
|
||||
return cv2.erode(img, kernel)
|
||||
|
||||
|
||||
def prim_convolve(img, kernel):
|
||||
"""Apply custom convolution kernel."""
|
||||
kernel = np.array(kernel, dtype=np.float32)
|
||||
return cv2.filter2D(img, -1, kernel)
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Blur
|
||||
'blur': prim_blur,
|
||||
'box-blur': prim_box_blur,
|
||||
'median-blur': prim_median_blur,
|
||||
'bilateral': prim_bilateral,
|
||||
|
||||
# Sharpen
|
||||
'sharpen': prim_sharpen,
|
||||
|
||||
# Edges
|
||||
'edges': prim_edges,
|
||||
'sobel': prim_sobel,
|
||||
'laplacian': prim_laplacian,
|
||||
|
||||
# Effects
|
||||
'emboss': prim_emboss,
|
||||
|
||||
# Morphology
|
||||
'dilate': prim_dilate,
|
||||
'erode': prim_erode,
|
||||
|
||||
# Custom
|
||||
'convolve': prim_convolve,
|
||||
}
|
||||
143
l1/sexp_effects/primitive_libs/geometry.py
Normal file
143
l1/sexp_effects/primitive_libs/geometry.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""
|
||||
Geometry Primitives Library
|
||||
|
||||
Geometric transforms: rotate, scale, flip, translate, remap.
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
|
||||
def prim_translate(img, dx, dy):
|
||||
"""Translate image by (dx, dy) pixels."""
|
||||
h, w = img.shape[:2]
|
||||
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
|
||||
def prim_rotate(img, angle, cx=None, cy=None):
|
||||
"""Rotate image by angle degrees around center (cx, cy)."""
|
||||
h, w = img.shape[:2]
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
|
||||
def prim_scale(img, sx, sy, cx=None, cy=None):
|
||||
"""Scale image by (sx, sy) around center (cx, cy)."""
|
||||
h, w = img.shape[:2]
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
|
||||
# Build transform matrix
|
||||
M = np.float32([
|
||||
[sx, 0, cx * (1 - sx)],
|
||||
[0, sy, cy * (1 - sy)]
|
||||
])
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
|
||||
def prim_flip_h(img):
|
||||
"""Flip image horizontally."""
|
||||
return cv2.flip(img, 1)
|
||||
|
||||
|
||||
def prim_flip_v(img):
|
||||
"""Flip image vertically."""
|
||||
return cv2.flip(img, 0)
|
||||
|
||||
|
||||
def prim_flip(img, direction="horizontal"):
|
||||
"""Flip image in given direction."""
|
||||
if direction in ("horizontal", "h"):
|
||||
return prim_flip_h(img)
|
||||
elif direction in ("vertical", "v"):
|
||||
return prim_flip_v(img)
|
||||
elif direction in ("both", "hv", "vh"):
|
||||
return cv2.flip(img, -1)
|
||||
return img
|
||||
|
||||
|
||||
def prim_transpose(img):
|
||||
"""Transpose image (swap x and y)."""
|
||||
return np.transpose(img, (1, 0, 2))
|
||||
|
||||
|
||||
def prim_remap(img, map_x, map_y):
|
||||
"""Remap image using coordinate maps."""
|
||||
return cv2.remap(img, map_x.astype(np.float32),
|
||||
map_y.astype(np.float32),
|
||||
cv2.INTER_LINEAR)
|
||||
|
||||
|
||||
def prim_make_coords(w, h):
|
||||
"""Create coordinate grids for remapping."""
|
||||
x = np.arange(w, dtype=np.float32)
|
||||
y = np.arange(h, dtype=np.float32)
|
||||
map_x, map_y = np.meshgrid(x, y)
|
||||
return (map_x, map_y)
|
||||
|
||||
|
||||
def prim_perspective(img, src_pts, dst_pts):
|
||||
"""Apply perspective transform."""
|
||||
src = np.float32(src_pts)
|
||||
dst = np.float32(dst_pts)
|
||||
M = cv2.getPerspectiveTransform(src, dst)
|
||||
h, w = img.shape[:2]
|
||||
return cv2.warpPerspective(img, M, (w, h))
|
||||
|
||||
|
||||
def prim_affine(img, src_pts, dst_pts):
|
||||
"""Apply affine transform using 3 point pairs."""
|
||||
src = np.float32(src_pts)
|
||||
dst = np.float32(dst_pts)
|
||||
M = cv2.getAffineTransform(src, dst)
|
||||
h, w = img.shape[:2]
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
|
||||
def _get_legacy_geometry_primitives():
|
||||
"""Import geometry primitives from legacy primitives module."""
|
||||
from sexp_effects.primitives import (
|
||||
prim_coords_x,
|
||||
prim_coords_y,
|
||||
prim_ripple_displace,
|
||||
prim_fisheye_displace,
|
||||
prim_kaleidoscope_displace,
|
||||
)
|
||||
return {
|
||||
'coords-x': prim_coords_x,
|
||||
'coords-y': prim_coords_y,
|
||||
'ripple-displace': prim_ripple_displace,
|
||||
'fisheye-displace': prim_fisheye_displace,
|
||||
'kaleidoscope-displace': prim_kaleidoscope_displace,
|
||||
}
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Basic transforms
|
||||
'translate': prim_translate,
|
||||
'rotate-img': prim_rotate,
|
||||
'scale-img': prim_scale,
|
||||
|
||||
# Flips
|
||||
'flip-h': prim_flip_h,
|
||||
'flip-v': prim_flip_v,
|
||||
'flip': prim_flip,
|
||||
'transpose': prim_transpose,
|
||||
|
||||
# Remapping
|
||||
'remap': prim_remap,
|
||||
'make-coords': prim_make_coords,
|
||||
|
||||
# Advanced transforms
|
||||
'perspective': prim_perspective,
|
||||
'affine': prim_affine,
|
||||
|
||||
# Displace / coordinate ops (from legacy primitives)
|
||||
**_get_legacy_geometry_primitives(),
|
||||
}
|
||||
403
l1/sexp_effects/primitive_libs/geometry_gpu.py
Normal file
403
l1/sexp_effects/primitive_libs/geometry_gpu.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""
|
||||
GPU-Accelerated Geometry Primitives Library
|
||||
|
||||
Uses CuPy for CUDA-accelerated image transforms.
|
||||
Falls back to CPU if GPU unavailable.
|
||||
|
||||
Performance Mode:
|
||||
- Set STREAMING_GPU_PERSIST=1 to keep frames on GPU between operations
|
||||
- This dramatically improves performance by avoiding CPU<->GPU transfers
|
||||
- Frames only transfer to CPU at final output
|
||||
"""
|
||||
import os
|
||||
import numpy as np
|
||||
|
||||
# Try to import CuPy for GPU acceleration
|
||||
try:
|
||||
import cupy as cp
|
||||
from cupyx.scipy import ndimage as cpndimage
|
||||
GPU_AVAILABLE = True
|
||||
print("[geometry_gpu] CuPy GPU acceleration enabled")
|
||||
except ImportError:
|
||||
cp = np
|
||||
GPU_AVAILABLE = False
|
||||
print("[geometry_gpu] CuPy not available, using CPU fallback")
|
||||
|
||||
# GPU persistence mode - keep frames on GPU between operations
|
||||
# Set STREAMING_GPU_PERSIST=1 for maximum performance
|
||||
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1"
|
||||
if GPU_AVAILABLE and GPU_PERSIST:
|
||||
print("[geometry_gpu] GPU persistence enabled - frames stay on GPU")
|
||||
|
||||
|
||||
def _to_gpu(img):
|
||||
"""Move image to GPU if available."""
|
||||
if GPU_AVAILABLE and not isinstance(img, cp.ndarray):
|
||||
return cp.asarray(img)
|
||||
return img
|
||||
|
||||
|
||||
def _to_cpu(img):
|
||||
"""Move image back to CPU (only if GPU_PERSIST is disabled)."""
|
||||
if not GPU_PERSIST and GPU_AVAILABLE and isinstance(img, cp.ndarray):
|
||||
return cp.asnumpy(img)
|
||||
return img
|
||||
|
||||
|
||||
def _ensure_output_format(img):
|
||||
"""Ensure output is in correct format based on GPU_PERSIST setting."""
|
||||
return _to_cpu(img)
|
||||
|
||||
|
||||
def prim_rotate(img, angle, cx=None, cy=None):
|
||||
"""Rotate image by angle degrees around center (cx, cy).
|
||||
|
||||
Uses fast CUDA kernel when available (< 1ms vs 20ms for scipy).
|
||||
"""
|
||||
if not GPU_AVAILABLE:
|
||||
# Fallback to OpenCV
|
||||
import cv2
|
||||
h, w = img.shape[:2]
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
M = cv2.getRotationMatrix2D((cx, cy), angle, 1.0)
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
# Use fast CUDA kernel (prim_rotate_gpu defined below)
|
||||
return prim_rotate_gpu(img, angle, cx, cy)
|
||||
|
||||
|
||||
def prim_scale(img, sx, sy, cx=None, cy=None):
|
||||
"""Scale image by (sx, sy) around center (cx, cy)."""
|
||||
if not GPU_AVAILABLE:
|
||||
import cv2
|
||||
h, w = img.shape[:2]
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
M = np.float32([
|
||||
[sx, 0, cx * (1 - sx)],
|
||||
[0, sy, cy * (1 - sy)]
|
||||
])
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
img_gpu = _to_gpu(img)
|
||||
h, w = img_gpu.shape[:2]
|
||||
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
|
||||
# Use cupyx.scipy.ndimage.zoom
|
||||
if img_gpu.ndim == 3:
|
||||
zoom_factors = (sy, sx, 1) # Don't zoom color channels
|
||||
else:
|
||||
zoom_factors = (sy, sx)
|
||||
|
||||
zoomed = cpndimage.zoom(img_gpu, zoom_factors, order=1)
|
||||
|
||||
# Crop/pad to original size
|
||||
zh, zw = zoomed.shape[:2]
|
||||
result = cp.zeros_like(img_gpu)
|
||||
|
||||
# Calculate offsets
|
||||
src_y = max(0, (zh - h) // 2)
|
||||
src_x = max(0, (zw - w) // 2)
|
||||
dst_y = max(0, (h - zh) // 2)
|
||||
dst_x = max(0, (w - zw) // 2)
|
||||
|
||||
copy_h = min(h - dst_y, zh - src_y)
|
||||
copy_w = min(w - dst_x, zw - src_x)
|
||||
|
||||
result[dst_y:dst_y+copy_h, dst_x:dst_x+copy_w] = zoomed[src_y:src_y+copy_h, src_x:src_x+copy_w]
|
||||
|
||||
return _to_cpu(result)
|
||||
|
||||
|
||||
def prim_translate(img, dx, dy):
|
||||
"""Translate image by (dx, dy) pixels."""
|
||||
if not GPU_AVAILABLE:
|
||||
import cv2
|
||||
h, w = img.shape[:2]
|
||||
M = np.float32([[1, 0, dx], [0, 1, dy]])
|
||||
return cv2.warpAffine(img, M, (w, h))
|
||||
|
||||
img_gpu = _to_gpu(img)
|
||||
# Use cupyx.scipy.ndimage.shift
|
||||
if img_gpu.ndim == 3:
|
||||
shift = (dy, dx, 0) # Don't shift color channels
|
||||
else:
|
||||
shift = (dy, dx)
|
||||
|
||||
shifted = cpndimage.shift(img_gpu, shift, order=1)
|
||||
return _to_cpu(shifted)
|
||||
|
||||
|
||||
def prim_flip_h(img):
|
||||
"""Flip image horizontally."""
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img)
|
||||
return _to_cpu(cp.flip(img_gpu, axis=1))
|
||||
return np.flip(img, axis=1)
|
||||
|
||||
|
||||
def prim_flip_v(img):
|
||||
"""Flip image vertically."""
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img)
|
||||
return _to_cpu(cp.flip(img_gpu, axis=0))
|
||||
return np.flip(img, axis=0)
|
||||
|
||||
|
||||
def prim_flip(img, direction="horizontal"):
|
||||
"""Flip image in given direction."""
|
||||
if direction in ("horizontal", "h"):
|
||||
return prim_flip_h(img)
|
||||
elif direction in ("vertical", "v"):
|
||||
return prim_flip_v(img)
|
||||
elif direction in ("both", "hv", "vh"):
|
||||
if GPU_AVAILABLE:
|
||||
img_gpu = _to_gpu(img)
|
||||
return _to_cpu(cp.flip(cp.flip(img_gpu, axis=0), axis=1))
|
||||
return np.flip(np.flip(img, axis=0), axis=1)
|
||||
return img
|
||||
|
||||
|
||||
# CUDA kernel for ripple effect
|
||||
if GPU_AVAILABLE:
|
||||
_ripple_kernel = cp.RawKernel(r'''
|
||||
extern "C" __global__
|
||||
void ripple(const unsigned char* src, unsigned char* dst,
|
||||
int width, int height, int channels,
|
||||
float amplitude, float frequency, float decay,
|
||||
float speed, float time, float cx, float cy) {
|
||||
int x = blockDim.x * blockIdx.x + threadIdx.x;
|
||||
int y = blockDim.y * blockIdx.y + threadIdx.y;
|
||||
|
||||
if (x >= width || y >= height) return;
|
||||
|
||||
// Distance from center
|
||||
float dx = x - cx;
|
||||
float dy = y - cy;
|
||||
float dist = sqrtf(dx * dx + dy * dy);
|
||||
|
||||
// Ripple displacement
|
||||
float wave = sinf(dist * frequency * 0.1f - time * speed) * amplitude;
|
||||
float falloff = expf(-dist * decay * 0.01f);
|
||||
float displacement = wave * falloff;
|
||||
|
||||
// Direction from center
|
||||
float len = dist + 0.0001f; // Avoid division by zero
|
||||
float dir_x = dx / len;
|
||||
float dir_y = dy / len;
|
||||
|
||||
// Source coordinates
|
||||
float src_x = x - dir_x * displacement;
|
||||
float src_y = y - dir_y * displacement;
|
||||
|
||||
// Clamp to bounds
|
||||
src_x = fmaxf(0.0f, fminf(width - 1.0f, src_x));
|
||||
src_y = fmaxf(0.0f, fminf(height - 1.0f, src_y));
|
||||
|
||||
// Bilinear interpolation
|
||||
int x0 = (int)src_x;
|
||||
int y0 = (int)src_y;
|
||||
int x1 = min(x0 + 1, width - 1);
|
||||
int y1 = min(y0 + 1, height - 1);
|
||||
|
||||
float fx = src_x - x0;
|
||||
float fy = src_y - y0;
|
||||
|
||||
for (int c = 0; c < channels; c++) {
|
||||
float v00 = src[(y0 * width + x0) * channels + c];
|
||||
float v10 = src[(y0 * width + x1) * channels + c];
|
||||
float v01 = src[(y1 * width + x0) * channels + c];
|
||||
float v11 = src[(y1 * width + x1) * channels + c];
|
||||
|
||||
float v0 = v00 * (1 - fx) + v10 * fx;
|
||||
float v1 = v01 * (1 - fx) + v11 * fx;
|
||||
float val = v0 * (1 - fy) + v1 * fy;
|
||||
|
||||
dst[(y * width + x) * channels + c] = (unsigned char)fminf(255.0f, fmaxf(0.0f, val));
|
||||
}
|
||||
}
|
||||
''', 'ripple')
|
||||
|
||||
|
||||
def prim_ripple(img, amplitude=10.0, frequency=8.0, decay=2.0, speed=5.0,
|
||||
time=0.0, center_x=None, center_y=None):
|
||||
"""Apply ripple distortion effect."""
|
||||
h, w = img.shape[:2]
|
||||
channels = img.shape[2] if img.ndim == 3 else 1
|
||||
|
||||
if center_x is None:
|
||||
center_x = w / 2
|
||||
if center_y is None:
|
||||
center_y = h / 2
|
||||
|
||||
if not GPU_AVAILABLE:
|
||||
# CPU fallback using coordinate mapping
|
||||
import cv2
|
||||
y_coords, x_coords = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
|
||||
dx = x_coords - center_x
|
||||
dy = y_coords - center_y
|
||||
dist = np.sqrt(dx**2 + dy**2)
|
||||
|
||||
wave = np.sin(dist * frequency * 0.1 - time * speed) * amplitude
|
||||
falloff = np.exp(-dist * decay * 0.01)
|
||||
displacement = wave * falloff
|
||||
|
||||
length = dist + 0.0001
|
||||
dir_x = dx / length
|
||||
dir_y = dy / length
|
||||
|
||||
map_x = (x_coords - dir_x * displacement).astype(np.float32)
|
||||
map_y = (y_coords - dir_y * displacement).astype(np.float32)
|
||||
|
||||
return cv2.remap(img, map_x, map_y, cv2.INTER_LINEAR)
|
||||
|
||||
# GPU implementation
|
||||
img_gpu = _to_gpu(img.astype(np.uint8))
|
||||
if img_gpu.ndim == 2:
|
||||
img_gpu = img_gpu[:, :, cp.newaxis]
|
||||
channels = 1
|
||||
|
||||
dst = cp.zeros_like(img_gpu)
|
||||
|
||||
block = (16, 16)
|
||||
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
|
||||
|
||||
_ripple_kernel(grid, block, (
|
||||
img_gpu, dst,
|
||||
np.int32(w), np.int32(h), np.int32(channels),
|
||||
np.float32(amplitude), np.float32(frequency), np.float32(decay),
|
||||
np.float32(speed), np.float32(time),
|
||||
np.float32(center_x), np.float32(center_y)
|
||||
))
|
||||
|
||||
result = _to_cpu(dst)
|
||||
if channels == 1:
|
||||
result = result[:, :, 0]
|
||||
return result
|
||||
|
||||
|
||||
# CUDA kernel for fast rotation with bilinear interpolation
|
||||
if GPU_AVAILABLE:
|
||||
_rotate_kernel = cp.RawKernel(r'''
|
||||
extern "C" __global__
|
||||
void rotate_img(const unsigned char* src, unsigned char* dst,
|
||||
int width, int height, int channels,
|
||||
float cos_a, float sin_a, float cx, float cy) {
|
||||
int x = blockDim.x * blockIdx.x + threadIdx.x;
|
||||
int y = blockDim.y * blockIdx.y + threadIdx.y;
|
||||
|
||||
if (x >= width || y >= height) return;
|
||||
|
||||
// Translate to center, rotate, translate back
|
||||
float dx = x - cx;
|
||||
float dy = y - cy;
|
||||
|
||||
float src_x = cos_a * dx + sin_a * dy + cx;
|
||||
float src_y = -sin_a * dx + cos_a * dy + cy;
|
||||
|
||||
// Check bounds
|
||||
if (src_x < 0 || src_x >= width - 1 || src_y < 0 || src_y >= height - 1) {
|
||||
for (int c = 0; c < channels; c++) {
|
||||
dst[(y * width + x) * channels + c] = 0;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Bilinear interpolation
|
||||
int x0 = (int)src_x;
|
||||
int y0 = (int)src_y;
|
||||
int x1 = x0 + 1;
|
||||
int y1 = y0 + 1;
|
||||
|
||||
float fx = src_x - x0;
|
||||
float fy = src_y - y0;
|
||||
|
||||
for (int c = 0; c < channels; c++) {
|
||||
float v00 = src[(y0 * width + x0) * channels + c];
|
||||
float v10 = src[(y0 * width + x1) * channels + c];
|
||||
float v01 = src[(y1 * width + x0) * channels + c];
|
||||
float v11 = src[(y1 * width + x1) * channels + c];
|
||||
|
||||
float v0 = v00 * (1 - fx) + v10 * fx;
|
||||
float v1 = v01 * (1 - fx) + v11 * fx;
|
||||
float val = v0 * (1 - fy) + v1 * fy;
|
||||
|
||||
dst[(y * width + x) * channels + c] = (unsigned char)fminf(255.0f, fmaxf(0.0f, val));
|
||||
}
|
||||
}
|
||||
''', 'rotate_img')
|
||||
|
||||
|
||||
def prim_rotate_gpu(img, angle, cx=None, cy=None):
|
||||
"""Fast GPU rotation using custom CUDA kernel."""
|
||||
if not GPU_AVAILABLE:
|
||||
return prim_rotate(img, angle, cx, cy)
|
||||
|
||||
h, w = img.shape[:2]
|
||||
channels = img.shape[2] if img.ndim == 3 else 1
|
||||
|
||||
if cx is None:
|
||||
cx = w / 2
|
||||
if cy is None:
|
||||
cy = h / 2
|
||||
|
||||
img_gpu = _to_gpu(img.astype(np.uint8))
|
||||
if img_gpu.ndim == 2:
|
||||
img_gpu = img_gpu[:, :, cp.newaxis]
|
||||
channels = 1
|
||||
|
||||
dst = cp.zeros_like(img_gpu)
|
||||
|
||||
# Convert angle to radians
|
||||
rad = np.radians(angle)
|
||||
cos_a = np.cos(rad)
|
||||
sin_a = np.sin(rad)
|
||||
|
||||
block = (16, 16)
|
||||
grid = ((w + block[0] - 1) // block[0], (h + block[1] - 1) // block[1])
|
||||
|
||||
_rotate_kernel(grid, block, (
|
||||
img_gpu, dst,
|
||||
np.int32(w), np.int32(h), np.int32(channels),
|
||||
np.float32(cos_a), np.float32(sin_a),
|
||||
np.float32(cx), np.float32(cy)
|
||||
))
|
||||
|
||||
result = _to_cpu(dst)
|
||||
if channels == 1:
|
||||
result = result[:, :, 0]
|
||||
return result
|
||||
|
||||
|
||||
# Import CPU primitives as fallbacks for functions we don't GPU-accelerate
|
||||
def _get_cpu_primitives():
|
||||
"""Get all primitives from CPU geometry module as fallbacks."""
|
||||
from sexp_effects.primitive_libs import geometry
|
||||
return geometry.PRIMITIVES
|
||||
|
||||
|
||||
# Export functions - start with CPU primitives, then override with GPU versions
|
||||
PRIMITIVES = _get_cpu_primitives().copy()
|
||||
|
||||
# Override specific primitives with GPU-accelerated versions
|
||||
PRIMITIVES.update({
|
||||
'translate': prim_translate,
|
||||
'rotate': prim_rotate_gpu if GPU_AVAILABLE else prim_rotate, # Fast CUDA kernel
|
||||
'rotate-img': prim_rotate_gpu if GPU_AVAILABLE else prim_rotate, # Alias
|
||||
'scale-img': prim_scale,
|
||||
'flip-h': prim_flip_h,
|
||||
'flip-v': prim_flip_v,
|
||||
'flip': prim_flip,
|
||||
'ripple': prim_ripple, # Fast CUDA kernel
|
||||
# Note: ripple-displace uses CPU version (different API - returns coords, not image)
|
||||
})
|
||||
150
l1/sexp_effects/primitive_libs/image.py
Normal file
150
l1/sexp_effects/primitive_libs/image.py
Normal file
@@ -0,0 +1,150 @@
|
||||
"""
|
||||
Image Primitives Library
|
||||
|
||||
Basic image operations: dimensions, pixels, resize, crop, paste.
|
||||
"""
|
||||
import numpy as np
|
||||
import cv2
|
||||
|
||||
|
||||
def prim_width(img):
|
||||
if isinstance(img, (list, tuple)):
|
||||
raise TypeError(f"image:width expects an image array, got {type(img).__name__} with {len(img)} elements")
|
||||
return img.shape[1]
|
||||
|
||||
|
||||
def prim_height(img):
|
||||
if isinstance(img, (list, tuple)):
|
||||
import sys
|
||||
print(f"DEBUG image:height got list: {img[:3]}... (types: {[type(x).__name__ for x in img[:3]]})", file=sys.stderr)
|
||||
raise TypeError(f"image:height expects an image array, got {type(img).__name__} with {len(img)} elements: {img}")
|
||||
return img.shape[0]
|
||||
|
||||
|
||||
def prim_make_image(w, h, color=None):
|
||||
"""Create a new image filled with color (default black)."""
|
||||
if color is None:
|
||||
color = [0, 0, 0]
|
||||
img = np.zeros((h, w, 3), dtype=np.uint8)
|
||||
img[:] = color
|
||||
return img
|
||||
|
||||
|
||||
def prim_copy(img):
|
||||
return img.copy()
|
||||
|
||||
|
||||
def prim_pixel(img, x, y):
|
||||
"""Get pixel color at (x, y) as [r, g, b]."""
|
||||
h, w = img.shape[:2]
|
||||
if 0 <= x < w and 0 <= y < h:
|
||||
return list(img[int(y), int(x)])
|
||||
return [0, 0, 0]
|
||||
|
||||
|
||||
def prim_set_pixel(img, x, y, color):
|
||||
"""Set pixel at (x, y) to color, returns modified image."""
|
||||
result = img.copy()
|
||||
h, w = result.shape[:2]
|
||||
if 0 <= x < w and 0 <= y < h:
|
||||
result[int(y), int(x)] = color
|
||||
return result
|
||||
|
||||
|
||||
def prim_sample(img, x, y):
|
||||
"""Bilinear sample at float coordinates, returns [r, g, b] as floats."""
|
||||
h, w = img.shape[:2]
|
||||
x = max(0, min(w - 1.001, x))
|
||||
y = max(0, min(h - 1.001, y))
|
||||
|
||||
x0, y0 = int(x), int(y)
|
||||
x1, y1 = min(x0 + 1, w - 1), min(y0 + 1, h - 1)
|
||||
fx, fy = x - x0, y - y0
|
||||
|
||||
c00 = img[y0, x0].astype(float)
|
||||
c10 = img[y0, x1].astype(float)
|
||||
c01 = img[y1, x0].astype(float)
|
||||
c11 = img[y1, x1].astype(float)
|
||||
|
||||
top = c00 * (1 - fx) + c10 * fx
|
||||
bottom = c01 * (1 - fx) + c11 * fx
|
||||
return list(top * (1 - fy) + bottom * fy)
|
||||
|
||||
|
||||
def prim_channel(img, c):
|
||||
"""Extract single channel (0=R, 1=G, 2=B)."""
|
||||
return img[:, :, c]
|
||||
|
||||
|
||||
def prim_merge_channels(r, g, b):
|
||||
"""Merge three single-channel arrays into RGB image."""
|
||||
return np.stack([r, g, b], axis=2).astype(np.uint8)
|
||||
|
||||
|
||||
def prim_resize(img, w, h, mode="linear"):
|
||||
"""Resize image to w x h."""
|
||||
interp = cv2.INTER_LINEAR
|
||||
if mode == "nearest":
|
||||
interp = cv2.INTER_NEAREST
|
||||
elif mode == "cubic":
|
||||
interp = cv2.INTER_CUBIC
|
||||
elif mode == "area":
|
||||
interp = cv2.INTER_AREA
|
||||
return cv2.resize(img, (int(w), int(h)), interpolation=interp)
|
||||
|
||||
|
||||
def prim_crop(img, x, y, w, h):
|
||||
"""Crop rectangle from image."""
|
||||
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||
ih, iw = img.shape[:2]
|
||||
x = max(0, min(x, iw - 1))
|
||||
y = max(0, min(y, ih - 1))
|
||||
w = min(w, iw - x)
|
||||
h = min(h, ih - y)
|
||||
return img[y:y+h, x:x+w].copy()
|
||||
|
||||
|
||||
def prim_paste(dst, src, x, y):
|
||||
"""Paste src onto dst at position (x, y)."""
|
||||
result = dst.copy()
|
||||
x, y = int(x), int(y)
|
||||
sh, sw = src.shape[:2]
|
||||
dh, dw = dst.shape[:2]
|
||||
|
||||
# Clip to bounds
|
||||
sx1 = max(0, -x)
|
||||
sy1 = max(0, -y)
|
||||
dx1 = max(0, x)
|
||||
dy1 = max(0, y)
|
||||
sx2 = min(sw, dw - x)
|
||||
sy2 = min(sh, dh - y)
|
||||
|
||||
if sx2 > sx1 and sy2 > sy1:
|
||||
result[dy1:dy1+(sy2-sy1), dx1:dx1+(sx2-sx1)] = src[sy1:sy2, sx1:sx2]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Dimensions
|
||||
'width': prim_width,
|
||||
'height': prim_height,
|
||||
|
||||
# Creation
|
||||
'make-image': prim_make_image,
|
||||
'copy': prim_copy,
|
||||
|
||||
# Pixel access
|
||||
'pixel': prim_pixel,
|
||||
'set-pixel': prim_set_pixel,
|
||||
'sample': prim_sample,
|
||||
|
||||
# Channels
|
||||
'channel': prim_channel,
|
||||
'merge-channels': prim_merge_channels,
|
||||
|
||||
# Geometry
|
||||
'resize': prim_resize,
|
||||
'crop': prim_crop,
|
||||
'paste': prim_paste,
|
||||
}
|
||||
164
l1/sexp_effects/primitive_libs/math.py
Normal file
164
l1/sexp_effects/primitive_libs/math.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""
|
||||
Math Primitives Library
|
||||
|
||||
Trigonometry, rounding, clamping, random numbers, etc.
|
||||
"""
|
||||
import math
|
||||
import random as rand_module
|
||||
|
||||
|
||||
def prim_sin(x):
|
||||
return math.sin(x)
|
||||
|
||||
|
||||
def prim_cos(x):
|
||||
return math.cos(x)
|
||||
|
||||
|
||||
def prim_tan(x):
|
||||
return math.tan(x)
|
||||
|
||||
|
||||
def prim_asin(x):
|
||||
return math.asin(x)
|
||||
|
||||
|
||||
def prim_acos(x):
|
||||
return math.acos(x)
|
||||
|
||||
|
||||
def prim_atan(x):
|
||||
return math.atan(x)
|
||||
|
||||
|
||||
def prim_atan2(y, x):
|
||||
return math.atan2(y, x)
|
||||
|
||||
|
||||
def prim_sqrt(x):
|
||||
return math.sqrt(x)
|
||||
|
||||
|
||||
def prim_pow(x, y):
|
||||
return math.pow(x, y)
|
||||
|
||||
|
||||
def prim_exp(x):
|
||||
return math.exp(x)
|
||||
|
||||
|
||||
def prim_log(x, base=None):
|
||||
if base is None:
|
||||
return math.log(x)
|
||||
return math.log(x, base)
|
||||
|
||||
|
||||
def prim_abs(x):
|
||||
return abs(x)
|
||||
|
||||
|
||||
def prim_floor(x):
|
||||
return math.floor(x)
|
||||
|
||||
|
||||
def prim_ceil(x):
|
||||
return math.ceil(x)
|
||||
|
||||
|
||||
def prim_round(x):
|
||||
return round(x)
|
||||
|
||||
|
||||
def prim_min(*args):
|
||||
if len(args) == 1 and hasattr(args[0], '__iter__'):
|
||||
return min(args[0])
|
||||
return min(args)
|
||||
|
||||
|
||||
def prim_max(*args):
|
||||
if len(args) == 1 and hasattr(args[0], '__iter__'):
|
||||
return max(args[0])
|
||||
return max(args)
|
||||
|
||||
|
||||
def prim_clamp(x, lo, hi):
|
||||
return max(lo, min(hi, x))
|
||||
|
||||
|
||||
def prim_lerp(a, b, t):
|
||||
"""Linear interpolation: a + (b - a) * t"""
|
||||
return a + (b - a) * t
|
||||
|
||||
|
||||
def prim_smoothstep(edge0, edge1, x):
|
||||
"""Smooth interpolation between 0 and 1."""
|
||||
t = prim_clamp((x - edge0) / (edge1 - edge0), 0.0, 1.0)
|
||||
return t * t * (3 - 2 * t)
|
||||
|
||||
|
||||
def prim_random(lo=0.0, hi=1.0):
|
||||
return rand_module.uniform(lo, hi)
|
||||
|
||||
|
||||
def prim_randint(lo, hi):
|
||||
return rand_module.randint(lo, hi)
|
||||
|
||||
|
||||
def prim_gaussian(mean=0.0, std=1.0):
|
||||
return rand_module.gauss(mean, std)
|
||||
|
||||
|
||||
def prim_sign(x):
|
||||
if x > 0:
|
||||
return 1
|
||||
elif x < 0:
|
||||
return -1
|
||||
return 0
|
||||
|
||||
|
||||
def prim_fract(x):
|
||||
"""Fractional part of x."""
|
||||
return x - math.floor(x)
|
||||
|
||||
|
||||
PRIMITIVES = {
|
||||
# Trigonometry
|
||||
'sin': prim_sin,
|
||||
'cos': prim_cos,
|
||||
'tan': prim_tan,
|
||||
'asin': prim_asin,
|
||||
'acos': prim_acos,
|
||||
'atan': prim_atan,
|
||||
'atan2': prim_atan2,
|
||||
|
||||
# Powers and roots
|
||||
'sqrt': prim_sqrt,
|
||||
'pow': prim_pow,
|
||||
'exp': prim_exp,
|
||||
'log': prim_log,
|
||||
|
||||
# Rounding
|
||||
'abs': prim_abs,
|
||||
'floor': prim_floor,
|
||||
'ceil': prim_ceil,
|
||||
'round': prim_round,
|
||||
'sign': prim_sign,
|
||||
'fract': prim_fract,
|
||||
|
||||
# Min/max/clamp
|
||||
'min': prim_min,
|
||||
'max': prim_max,
|
||||
'clamp': prim_clamp,
|
||||
'lerp': prim_lerp,
|
||||
'smoothstep': prim_smoothstep,
|
||||
|
||||
# Random
|
||||
'random': prim_random,
|
||||
'randint': prim_randint,
|
||||
'gaussian': prim_gaussian,
|
||||
|
||||
# Constants
|
||||
'pi': math.pi,
|
||||
'tau': math.tau,
|
||||
'e': math.e,
|
||||
}
|
||||
593
l1/sexp_effects/primitive_libs/streaming.py
Normal file
593
l1/sexp_effects/primitive_libs/streaming.py
Normal file
@@ -0,0 +1,593 @@
|
||||
"""
|
||||
Streaming primitives for video/audio processing.
|
||||
|
||||
These primitives handle video source reading and audio analysis,
|
||||
keeping the interpreter completely generic.
|
||||
|
||||
GPU Acceleration:
|
||||
- Set STREAMING_GPU_PERSIST=1 to output CuPy arrays (frames stay on GPU)
|
||||
- Hardware video decoding (NVDEC) is used when available
|
||||
- Dramatically improves performance on GPU nodes
|
||||
|
||||
Async Prefetching:
|
||||
- Set STREAMING_PREFETCH=1 to enable background frame prefetching
|
||||
- Decodes upcoming frames while current frame is being processed
|
||||
"""
|
||||
|
||||
import os
|
||||
import numpy as np
|
||||
import subprocess
|
||||
import json
|
||||
import threading
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
|
||||
# Try to import CuPy for GPU acceleration
|
||||
try:
|
||||
import cupy as cp
|
||||
CUPY_AVAILABLE = True
|
||||
except ImportError:
|
||||
cp = None
|
||||
CUPY_AVAILABLE = False
|
||||
|
||||
# GPU persistence mode - output CuPy arrays instead of numpy
|
||||
# Disabled by default until all primitives support GPU frames
|
||||
GPU_PERSIST = os.environ.get("STREAMING_GPU_PERSIST", "0") == "1" and CUPY_AVAILABLE
|
||||
|
||||
# Async prefetch mode - decode frames in background thread
|
||||
PREFETCH_ENABLED = os.environ.get("STREAMING_PREFETCH", "1") == "1"
|
||||
PREFETCH_BUFFER_SIZE = int(os.environ.get("STREAMING_PREFETCH_SIZE", "10"))
|
||||
|
||||
# Check for hardware decode support (cached)
|
||||
_HWDEC_AVAILABLE = None
|
||||
|
||||
|
||||
def _check_hwdec():
|
||||
"""Check if NVIDIA hardware decode is available."""
|
||||
global _HWDEC_AVAILABLE
|
||||
if _HWDEC_AVAILABLE is not None:
|
||||
return _HWDEC_AVAILABLE
|
||||
|
||||
try:
|
||||
result = subprocess.run(["nvidia-smi"], capture_output=True, timeout=2)
|
||||
if result.returncode != 0:
|
||||
_HWDEC_AVAILABLE = False
|
||||
return False
|
||||
result = subprocess.run(["ffmpeg", "-hwaccels"], capture_output=True, text=True, timeout=5)
|
||||
_HWDEC_AVAILABLE = "cuda" in result.stdout
|
||||
except Exception:
|
||||
_HWDEC_AVAILABLE = False
|
||||
|
||||
return _HWDEC_AVAILABLE
|
||||
|
||||
|
||||
class VideoSource:
|
||||
"""Video source with persistent streaming pipe for fast sequential reads."""
|
||||
|
||||
def __init__(self, path: str, fps: float = 30):
|
||||
self.path = Path(path)
|
||||
self.fps = fps # Output fps for the stream
|
||||
self._frame_size = None
|
||||
self._duration = None
|
||||
self._proc = None # Persistent ffmpeg process
|
||||
self._stream_time = 0.0 # Current position in stream
|
||||
self._frame_time = 1.0 / fps # Time per frame at output fps
|
||||
self._last_read_time = -1
|
||||
self._cached_frame = None
|
||||
|
||||
# Check if file exists
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(f"Video file not found: {self.path}")
|
||||
|
||||
# Get video info
|
||||
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_streams", str(self.path)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to probe video '{self.path}': {result.stderr}")
|
||||
try:
|
||||
info = json.loads(result.stdout)
|
||||
except json.JSONDecodeError:
|
||||
raise RuntimeError(f"Invalid video file or ffprobe failed: {self.path}")
|
||||
|
||||
for stream in info.get("streams", []):
|
||||
if stream.get("codec_type") == "video":
|
||||
self._frame_size = (stream.get("width", 720), stream.get("height", 720))
|
||||
# Try direct duration field first
|
||||
if "duration" in stream:
|
||||
self._duration = float(stream["duration"])
|
||||
# Fall back to tags.DURATION (webm format: "00:01:00.124000000")
|
||||
elif "tags" in stream and "DURATION" in stream["tags"]:
|
||||
dur_str = stream["tags"]["DURATION"]
|
||||
parts = dur_str.split(":")
|
||||
if len(parts) == 3:
|
||||
h, m, s = parts
|
||||
self._duration = int(h) * 3600 + int(m) * 60 + float(s)
|
||||
break
|
||||
|
||||
# Fallback: check format duration if stream duration not found
|
||||
if self._duration is None and "format" in info and "duration" in info["format"]:
|
||||
self._duration = float(info["format"]["duration"])
|
||||
|
||||
if not self._frame_size:
|
||||
self._frame_size = (720, 720)
|
||||
|
||||
import sys
|
||||
print(f"VideoSource: {self.path.name} duration={self._duration} size={self._frame_size}", file=sys.stderr)
|
||||
|
||||
def _start_stream(self, seek_time: float = 0):
|
||||
"""Start or restart the ffmpeg streaming process.
|
||||
|
||||
Uses NVIDIA hardware decoding (NVDEC) when available for better performance.
|
||||
"""
|
||||
if self._proc:
|
||||
self._proc.kill()
|
||||
self._proc = None
|
||||
|
||||
# Check file exists before trying to open
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(f"Video file not found: {self.path}")
|
||||
|
||||
w, h = self._frame_size
|
||||
|
||||
# Build ffmpeg command with optional hardware decode
|
||||
cmd = ["ffmpeg", "-v", "error"]
|
||||
|
||||
# Use hardware decode if available (significantly faster)
|
||||
if _check_hwdec():
|
||||
cmd.extend(["-hwaccel", "cuda"])
|
||||
|
||||
cmd.extend([
|
||||
"-ss", f"{seek_time:.3f}",
|
||||
"-i", str(self.path),
|
||||
"-f", "rawvideo", "-pix_fmt", "rgb24",
|
||||
"-s", f"{w}x{h}",
|
||||
"-r", str(self.fps), # Output at specified fps
|
||||
"-"
|
||||
])
|
||||
|
||||
self._proc = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
|
||||
self._stream_time = seek_time
|
||||
|
||||
# Check if process started successfully by reading first bit of stderr
|
||||
import select
|
||||
import sys
|
||||
readable, _, _ = select.select([self._proc.stderr], [], [], 0.5)
|
||||
if readable:
|
||||
err = self._proc.stderr.read(4096).decode('utf-8', errors='ignore')
|
||||
if err:
|
||||
print(f"ffmpeg error for {self.path.name}: {err}", file=sys.stderr)
|
||||
|
||||
def _read_frame_from_stream(self):
|
||||
"""Read one frame from the stream.
|
||||
|
||||
Returns CuPy array if GPU_PERSIST is enabled, numpy array otherwise.
|
||||
"""
|
||||
w, h = self._frame_size
|
||||
frame_size = w * h * 3
|
||||
|
||||
if not self._proc or self._proc.poll() is not None:
|
||||
return None
|
||||
|
||||
data = self._proc.stdout.read(frame_size)
|
||||
if len(data) < frame_size:
|
||||
return None
|
||||
|
||||
frame = np.frombuffer(data, dtype=np.uint8).reshape((h, w, 3)).copy()
|
||||
|
||||
# Transfer to GPU if persistence mode enabled
|
||||
if GPU_PERSIST:
|
||||
return cp.asarray(frame)
|
||||
return frame
|
||||
|
||||
def read(self) -> np.ndarray:
|
||||
"""Read frame (uses last cached or t=0)."""
|
||||
if self._cached_frame is not None:
|
||||
return self._cached_frame
|
||||
return self.read_at(0)
|
||||
|
||||
def read_at(self, t: float) -> np.ndarray:
|
||||
"""Read frame at specific time using streaming with smart seeking."""
|
||||
# Cache check - return same frame for same time
|
||||
if t == self._last_read_time and self._cached_frame is not None:
|
||||
return self._cached_frame
|
||||
|
||||
w, h = self._frame_size
|
||||
|
||||
# Loop time if video is shorter
|
||||
seek_time = t
|
||||
if self._duration and self._duration > 0:
|
||||
seek_time = t % self._duration
|
||||
# If we're within 0.1s of the end, wrap to beginning to avoid EOF issues
|
||||
if seek_time > self._duration - 0.1:
|
||||
seek_time = 0.0
|
||||
|
||||
# Decide whether to seek or continue streaming
|
||||
# Seek if: no stream, going backwards (more than 1 frame), or jumping more than 2 seconds ahead
|
||||
# Allow small backward tolerance to handle floating point and timing jitter
|
||||
need_seek = (
|
||||
self._proc is None or
|
||||
self._proc.poll() is not None or
|
||||
seek_time < self._stream_time - self._frame_time or # More than 1 frame backward
|
||||
seek_time > self._stream_time + 2.0
|
||||
)
|
||||
|
||||
if need_seek:
|
||||
import sys
|
||||
reason = "no proc" if self._proc is None else "proc dead" if self._proc.poll() is not None else "backward" if seek_time < self._stream_time else "jump"
|
||||
print(f"SEEK {self.path.name}: t={t:.4f} seek={seek_time:.4f} stream={self._stream_time:.4f} ({reason})", file=sys.stderr)
|
||||
self._start_stream(seek_time)
|
||||
|
||||
# Skip frames to reach target time
|
||||
skip_retries = 0
|
||||
while self._stream_time + self._frame_time <= seek_time:
|
||||
frame = self._read_frame_from_stream()
|
||||
if frame is None:
|
||||
# Stream ended or failed - restart from seek point
|
||||
import time
|
||||
skip_retries += 1
|
||||
if skip_retries > 3:
|
||||
# Give up skipping, just start fresh at seek_time
|
||||
self._start_stream(seek_time)
|
||||
time.sleep(0.1)
|
||||
break
|
||||
self._start_stream(seek_time)
|
||||
time.sleep(0.05)
|
||||
continue
|
||||
self._stream_time += self._frame_time
|
||||
skip_retries = 0 # Reset on successful read
|
||||
|
||||
# Read the target frame with retry logic
|
||||
frame = None
|
||||
max_retries = 3
|
||||
for attempt in range(max_retries):
|
||||
frame = self._read_frame_from_stream()
|
||||
if frame is not None:
|
||||
break
|
||||
|
||||
# Stream failed - try restarting
|
||||
import sys
|
||||
import time
|
||||
print(f"RETRY {self.path.name}: attempt {attempt+1}/{max_retries} at t={t:.2f}", file=sys.stderr)
|
||||
|
||||
# Check for ffmpeg errors
|
||||
if self._proc and self._proc.stderr:
|
||||
try:
|
||||
import select
|
||||
readable, _, _ = select.select([self._proc.stderr], [], [], 0.1)
|
||||
if readable:
|
||||
err = self._proc.stderr.read(4096).decode('utf-8', errors='ignore')
|
||||
if err:
|
||||
print(f"ffmpeg error: {err}", file=sys.stderr)
|
||||
except:
|
||||
pass
|
||||
|
||||
# Wait a bit and restart
|
||||
time.sleep(0.1)
|
||||
self._start_stream(seek_time)
|
||||
|
||||
# Give ffmpeg time to start
|
||||
time.sleep(0.1)
|
||||
|
||||
if frame is None:
|
||||
import sys
|
||||
raise RuntimeError(f"Failed to read video frame from {self.path.name} at t={t:.2f} after {max_retries} retries")
|
||||
else:
|
||||
self._stream_time += self._frame_time
|
||||
|
||||
self._last_read_time = t
|
||||
self._cached_frame = frame
|
||||
return frame
|
||||
|
||||
def skip(self):
|
||||
"""No-op for seek-based reading."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._frame_size
|
||||
|
||||
def close(self):
|
||||
if self._proc:
|
||||
self._proc.kill()
|
||||
self._proc = None
|
||||
|
||||
|
||||
class PrefetchingVideoSource:
|
||||
"""
|
||||
Video source with background prefetching for improved performance.
|
||||
|
||||
Wraps VideoSource and adds a background thread that pre-decodes
|
||||
upcoming frames while the main thread processes the current frame.
|
||||
"""
|
||||
|
||||
def __init__(self, path: str, fps: float = 30, buffer_size: int = None):
|
||||
self._source = VideoSource(path, fps)
|
||||
self._buffer_size = buffer_size or PREFETCH_BUFFER_SIZE
|
||||
self._buffer = {} # time -> frame
|
||||
self._buffer_lock = threading.Lock()
|
||||
self._prefetch_time = 0.0
|
||||
self._frame_time = 1.0 / fps
|
||||
self._stop_event = threading.Event()
|
||||
self._request_event = threading.Event()
|
||||
self._target_time = 0.0
|
||||
|
||||
# Start prefetch thread
|
||||
self._thread = threading.Thread(target=self._prefetch_loop, daemon=True)
|
||||
self._thread.start()
|
||||
|
||||
import sys
|
||||
print(f"PrefetchingVideoSource: {path} buffer_size={self._buffer_size}", file=sys.stderr)
|
||||
|
||||
def _prefetch_loop(self):
|
||||
"""Background thread that pre-reads frames."""
|
||||
while not self._stop_event.is_set():
|
||||
# Wait for work or timeout
|
||||
self._request_event.wait(timeout=0.01)
|
||||
self._request_event.clear()
|
||||
|
||||
if self._stop_event.is_set():
|
||||
break
|
||||
|
||||
# Prefetch frames ahead of target time
|
||||
target = self._target_time
|
||||
with self._buffer_lock:
|
||||
# Clean old frames (more than 1 second behind)
|
||||
old_times = [t for t in self._buffer.keys() if t < target - 1.0]
|
||||
for t in old_times:
|
||||
del self._buffer[t]
|
||||
|
||||
# Count how many frames we have buffered ahead
|
||||
buffered_ahead = sum(1 for t in self._buffer.keys() if t >= target)
|
||||
|
||||
# Prefetch if buffer not full
|
||||
if buffered_ahead < self._buffer_size:
|
||||
# Find next time to prefetch
|
||||
prefetch_t = target
|
||||
with self._buffer_lock:
|
||||
existing_times = set(self._buffer.keys())
|
||||
for _ in range(self._buffer_size):
|
||||
if prefetch_t not in existing_times:
|
||||
break
|
||||
prefetch_t += self._frame_time
|
||||
|
||||
# Read the frame (this is the slow part)
|
||||
try:
|
||||
frame = self._source.read_at(prefetch_t)
|
||||
with self._buffer_lock:
|
||||
self._buffer[prefetch_t] = frame
|
||||
except Exception as e:
|
||||
import sys
|
||||
print(f"Prefetch error at t={prefetch_t}: {e}", file=sys.stderr)
|
||||
|
||||
def read_at(self, t: float) -> np.ndarray:
|
||||
"""Read frame at specific time, using prefetch buffer if available."""
|
||||
self._target_time = t
|
||||
self._request_event.set() # Wake up prefetch thread
|
||||
|
||||
# Round to frame time for buffer lookup
|
||||
t_key = round(t / self._frame_time) * self._frame_time
|
||||
|
||||
# Check buffer first
|
||||
with self._buffer_lock:
|
||||
if t_key in self._buffer:
|
||||
return self._buffer[t_key]
|
||||
# Also check for close matches (within half frame time)
|
||||
for buf_t, frame in self._buffer.items():
|
||||
if abs(buf_t - t) < self._frame_time * 0.5:
|
||||
return frame
|
||||
|
||||
# Not in buffer - read directly (blocking)
|
||||
frame = self._source.read_at(t)
|
||||
|
||||
# Store in buffer
|
||||
with self._buffer_lock:
|
||||
self._buffer[t_key] = frame
|
||||
|
||||
return frame
|
||||
|
||||
def read(self) -> np.ndarray:
|
||||
"""Read frame (uses last cached or t=0)."""
|
||||
return self.read_at(0)
|
||||
|
||||
def skip(self):
|
||||
"""No-op for seek-based reading."""
|
||||
pass
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return self._source.size
|
||||
|
||||
@property
|
||||
def path(self):
|
||||
return self._source.path
|
||||
|
||||
def close(self):
|
||||
self._stop_event.set()
|
||||
self._request_event.set() # Wake up thread to exit
|
||||
self._thread.join(timeout=1.0)
|
||||
self._source.close()
|
||||
|
||||
|
||||
class AudioAnalyzer:
|
||||
"""Audio analyzer for energy and beat detection."""
|
||||
|
||||
def __init__(self, path: str, sample_rate: int = 22050):
|
||||
self.path = Path(path)
|
||||
self.sample_rate = sample_rate
|
||||
|
||||
# Check if file exists
|
||||
if not self.path.exists():
|
||||
raise FileNotFoundError(f"Audio file not found: {self.path}")
|
||||
|
||||
# Load audio via ffmpeg
|
||||
cmd = ["ffmpeg", "-v", "error", "-i", str(self.path),
|
||||
"-f", "f32le", "-ac", "1", "-ar", str(sample_rate), "-"]
|
||||
result = subprocess.run(cmd, capture_output=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to load audio '{self.path}': {result.stderr.decode()}")
|
||||
self._audio = np.frombuffer(result.stdout, dtype=np.float32)
|
||||
if len(self._audio) == 0:
|
||||
raise RuntimeError(f"Audio file is empty or invalid: {self.path}")
|
||||
|
||||
# Get duration
|
||||
cmd = ["ffprobe", "-v", "quiet", "-print_format", "json",
|
||||
"-show_format", str(self.path)]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"Failed to probe audio '{self.path}': {result.stderr}")
|
||||
info = json.loads(result.stdout)
|
||||
self.duration = float(info.get("format", {}).get("duration", 60))
|
||||
|
||||
# Beat detection state
|
||||
self._flux_history = []
|
||||
self._last_beat_time = -1
|
||||
self._beat_count = 0
|
||||
self._last_beat_check_time = -1
|
||||
# Cache beat result for current time (so multiple scans see same result)
|
||||
self._beat_cache_time = -1
|
||||
self._beat_cache_result = False
|
||||
|
||||
def get_energy(self, t: float) -> float:
|
||||
"""Get energy level at time t (0-1)."""
|
||||
idx = int(t * self.sample_rate)
|
||||
start = max(0, idx - 512)
|
||||
end = min(len(self._audio), idx + 512)
|
||||
if start >= end:
|
||||
return 0.0
|
||||
return min(1.0, np.sqrt(np.mean(self._audio[start:end] ** 2)) * 3.0)
|
||||
|
||||
def get_beat(self, t: float) -> bool:
|
||||
"""Check if there's a beat at time t."""
|
||||
# Return cached result if same time (multiple scans query same frame)
|
||||
if t == self._beat_cache_time:
|
||||
return self._beat_cache_result
|
||||
|
||||
idx = int(t * self.sample_rate)
|
||||
size = 2048
|
||||
|
||||
start, end = max(0, idx - size//2), min(len(self._audio), idx + size//2)
|
||||
if end - start < size/2:
|
||||
self._beat_cache_time = t
|
||||
self._beat_cache_result = False
|
||||
return False
|
||||
curr = self._audio[start:end]
|
||||
|
||||
pstart, pend = max(0, start - 512), max(0, end - 512)
|
||||
if pend <= pstart:
|
||||
self._beat_cache_time = t
|
||||
self._beat_cache_result = False
|
||||
return False
|
||||
prev = self._audio[pstart:pend]
|
||||
|
||||
curr_spec = np.abs(np.fft.rfft(curr * np.hanning(len(curr))))
|
||||
prev_spec = np.abs(np.fft.rfft(prev * np.hanning(len(prev))))
|
||||
|
||||
n = min(len(curr_spec), len(prev_spec))
|
||||
flux = np.sum(np.maximum(0, curr_spec[:n] - prev_spec[:n])) / (n + 1)
|
||||
|
||||
self._flux_history.append((t, flux))
|
||||
if len(self._flux_history) > 50:
|
||||
self._flux_history = self._flux_history[-50:]
|
||||
|
||||
if len(self._flux_history) < 5:
|
||||
self._beat_cache_time = t
|
||||
self._beat_cache_result = False
|
||||
return False
|
||||
|
||||
recent = [f for _, f in self._flux_history[-20:]]
|
||||
threshold = np.mean(recent) + 1.5 * np.std(recent)
|
||||
|
||||
is_beat = flux > threshold and (t - self._last_beat_time) > 0.1
|
||||
if is_beat:
|
||||
self._last_beat_time = t
|
||||
if t > self._last_beat_check_time:
|
||||
self._beat_count += 1
|
||||
self._last_beat_check_time = t
|
||||
|
||||
# Cache result for this time
|
||||
self._beat_cache_time = t
|
||||
self._beat_cache_result = is_beat
|
||||
return is_beat
|
||||
|
||||
def get_beat_count(self, t: float) -> int:
|
||||
"""Get cumulative beat count up to time t."""
|
||||
# Ensure beat detection has run up to this time
|
||||
self.get_beat(t)
|
||||
return self._beat_count
|
||||
|
||||
|
||||
# === Primitives ===
|
||||
|
||||
def prim_make_video_source(path: str, fps: float = 30):
|
||||
"""Create a video source from a file path.
|
||||
|
||||
Uses PrefetchingVideoSource if STREAMING_PREFETCH=1 (default).
|
||||
"""
|
||||
if PREFETCH_ENABLED:
|
||||
return PrefetchingVideoSource(path, fps)
|
||||
return VideoSource(path, fps)
|
||||
|
||||
|
||||
def prim_source_read(source: VideoSource, t: float = None):
|
||||
"""Read a frame from a video source."""
|
||||
import sys
|
||||
if t is not None:
|
||||
frame = source.read_at(t)
|
||||
# Debug: show source and time
|
||||
if int(t * 10) % 10 == 0: # Every second
|
||||
print(f"READ {source.path.name}: t={t:.2f} stream={source._stream_time:.2f}", file=sys.stderr)
|
||||
return frame
|
||||
return source.read()
|
||||
|
||||
|
||||
def prim_source_skip(source: VideoSource):
|
||||
"""Skip a frame (keep pipe in sync)."""
|
||||
source.skip()
|
||||
|
||||
|
||||
def prim_source_size(source: VideoSource):
|
||||
"""Get (width, height) of source."""
|
||||
return source.size
|
||||
|
||||
|
||||
def prim_make_audio_analyzer(path: str):
|
||||
"""Create an audio analyzer from a file path."""
|
||||
return AudioAnalyzer(path)
|
||||
|
||||
|
||||
def prim_audio_energy(analyzer: AudioAnalyzer, t: float) -> float:
|
||||
"""Get energy level (0-1) at time t."""
|
||||
return analyzer.get_energy(t)
|
||||
|
||||
|
||||
def prim_audio_beat(analyzer: AudioAnalyzer, t: float) -> bool:
|
||||
"""Check if there's a beat at time t."""
|
||||
return analyzer.get_beat(t)
|
||||
|
||||
|
||||
def prim_audio_beat_count(analyzer: AudioAnalyzer, t: float) -> int:
|
||||
"""Get cumulative beat count up to time t."""
|
||||
return analyzer.get_beat_count(t)
|
||||
|
||||
|
||||
def prim_audio_duration(analyzer: AudioAnalyzer) -> float:
|
||||
"""Get audio duration in seconds."""
|
||||
return analyzer.duration
|
||||
|
||||
|
||||
# Export primitives
|
||||
PRIMITIVES = {
|
||||
# Video source
|
||||
'make-video-source': prim_make_video_source,
|
||||
'source-read': prim_source_read,
|
||||
'source-skip': prim_source_skip,
|
||||
'source-size': prim_source_size,
|
||||
|
||||
# Audio analyzer
|
||||
'make-audio-analyzer': prim_make_audio_analyzer,
|
||||
'audio-energy': prim_audio_energy,
|
||||
'audio-beat': prim_audio_beat,
|
||||
'audio-beat-count': prim_audio_beat_count,
|
||||
'audio-duration': prim_audio_duration,
|
||||
}
|
||||
1165
l1/sexp_effects/primitive_libs/streaming_gpu.py
Normal file
1165
l1/sexp_effects/primitive_libs/streaming_gpu.py
Normal file
File diff suppressed because it is too large
Load Diff
1382
l1/sexp_effects/primitive_libs/xector.py
Normal file
1382
l1/sexp_effects/primitive_libs/xector.py
Normal file
File diff suppressed because it is too large
Load Diff
3075
l1/sexp_effects/primitives.py
Normal file
3075
l1/sexp_effects/primitives.py
Normal file
File diff suppressed because it is too large
Load Diff
236
l1/sexp_effects/test_interpreter.py
Normal file
236
l1/sexp_effects/test_interpreter.py
Normal file
@@ -0,0 +1,236 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test the S-expression effect interpreter.
|
||||
"""
|
||||
|
||||
import numpy as np
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Add parent to path
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from sexp_effects import (
|
||||
get_interpreter,
|
||||
load_effects_dir,
|
||||
run_effect,
|
||||
list_effects,
|
||||
parse,
|
||||
)
|
||||
|
||||
|
||||
def test_parser():
|
||||
"""Test S-expression parser."""
|
||||
print("Testing parser...")
|
||||
|
||||
# Simple expressions
|
||||
assert parse("42") == 42
|
||||
assert parse("3.14") == 3.14
|
||||
assert parse('"hello"') == "hello"
|
||||
assert parse("true") == True
|
||||
|
||||
# Lists
|
||||
assert parse("(+ 1 2)")[0].name == "+"
|
||||
assert parse("(+ 1 2)")[1] == 1
|
||||
|
||||
# Nested
|
||||
expr = parse("(define x (+ 1 2))")
|
||||
assert expr[0].name == "define"
|
||||
|
||||
print(" Parser OK")
|
||||
|
||||
|
||||
def test_interpreter_basics():
|
||||
"""Test basic interpreter operations."""
|
||||
print("Testing interpreter basics...")
|
||||
|
||||
interp = get_interpreter()
|
||||
|
||||
# Math
|
||||
assert interp.eval(parse("(+ 1 2)")) == 3
|
||||
assert interp.eval(parse("(* 3 4)")) == 12
|
||||
assert interp.eval(parse("(- 10 3)")) == 7
|
||||
|
||||
# Comparison
|
||||
assert interp.eval(parse("(< 1 2)")) == True
|
||||
assert interp.eval(parse("(> 1 2)")) == False
|
||||
|
||||
# Let binding
|
||||
assert interp.eval(parse("(let ((x 5)) x)")) == 5
|
||||
assert interp.eval(parse("(let ((x 5) (y 3)) (+ x y))")) == 8
|
||||
|
||||
# Lambda
|
||||
result = interp.eval(parse("((lambda (x) (* x 2)) 5)"))
|
||||
assert result == 10
|
||||
|
||||
# If
|
||||
assert interp.eval(parse("(if true 1 2)")) == 1
|
||||
assert interp.eval(parse("(if false 1 2)")) == 2
|
||||
|
||||
print(" Interpreter basics OK")
|
||||
|
||||
|
||||
def test_primitives():
|
||||
"""Test image primitives."""
|
||||
print("Testing primitives...")
|
||||
|
||||
interp = get_interpreter()
|
||||
|
||||
# Create test image
|
||||
img = np.zeros((100, 100, 3), dtype=np.uint8)
|
||||
img[50, 50] = [255, 128, 64]
|
||||
|
||||
interp.global_env.set('test_img', img)
|
||||
|
||||
# Width/height
|
||||
assert interp.eval(parse("(width test_img)")) == 100
|
||||
assert interp.eval(parse("(height test_img)")) == 100
|
||||
|
||||
# Pixel
|
||||
pixel = interp.eval(parse("(pixel test_img 50 50)"))
|
||||
assert pixel == [255, 128, 64]
|
||||
|
||||
# RGB
|
||||
color = interp.eval(parse("(rgb 100 150 200)"))
|
||||
assert color == [100, 150, 200]
|
||||
|
||||
# Luminance
|
||||
lum = interp.eval(parse("(luminance (rgb 100 100 100))"))
|
||||
assert abs(lum - 100) < 1
|
||||
|
||||
print(" Primitives OK")
|
||||
|
||||
|
||||
def test_effect_loading():
|
||||
"""Test loading effects from .sexp files."""
|
||||
print("Testing effect loading...")
|
||||
|
||||
# Load all effects
|
||||
effects_dir = Path(__file__).parent / "effects"
|
||||
load_effects_dir(str(effects_dir))
|
||||
|
||||
effects = list_effects()
|
||||
print(f" Loaded {len(effects)} effects: {', '.join(sorted(effects))}")
|
||||
|
||||
assert len(effects) > 0
|
||||
print(" Effect loading OK")
|
||||
|
||||
|
||||
def test_effect_execution():
|
||||
"""Test running effects on images."""
|
||||
print("Testing effect execution...")
|
||||
|
||||
# Create test image
|
||||
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
|
||||
|
||||
# Load effects
|
||||
effects_dir = Path(__file__).parent / "effects"
|
||||
load_effects_dir(str(effects_dir))
|
||||
|
||||
# Test each effect
|
||||
effects = list_effects()
|
||||
passed = 0
|
||||
failed = []
|
||||
|
||||
for name in sorted(effects):
|
||||
try:
|
||||
result, state = run_effect(name, img.copy(), {'_time': 0.5}, {})
|
||||
assert isinstance(result, np.ndarray)
|
||||
assert result.shape == img.shape
|
||||
passed += 1
|
||||
print(f" {name}: OK")
|
||||
except Exception as e:
|
||||
failed.append((name, str(e)))
|
||||
print(f" {name}: FAILED - {e}")
|
||||
|
||||
print(f" Passed: {passed}/{len(effects)}")
|
||||
if failed:
|
||||
print(f" Failed: {[f[0] for f in failed]}")
|
||||
|
||||
return passed, failed
|
||||
|
||||
|
||||
def test_ascii_fx_zone():
|
||||
"""Test ascii_fx_zone effect with zone expressions."""
|
||||
print("Testing ascii_fx_zone...")
|
||||
|
||||
interp = get_interpreter()
|
||||
|
||||
# Load the effect
|
||||
effects_dir = Path(__file__).parent / "effects"
|
||||
load_effects_dir(str(effects_dir))
|
||||
|
||||
# Create gradient test frame
|
||||
frame = np.zeros((120, 160, 3), dtype=np.uint8)
|
||||
for x in range(160):
|
||||
frame[:, x] = int(x / 160 * 255)
|
||||
frame = np.stack([frame[:,:,0]]*3, axis=2)
|
||||
|
||||
# Test 1: Basic without expressions
|
||||
result, _ = run_effect('ascii_fx_zone', frame, {'cols': 20}, {})
|
||||
assert result.shape == frame.shape
|
||||
print(" Basic run: OK")
|
||||
|
||||
# Test 2: With zone-lum expression
|
||||
expr = parse('(* zone-lum 180)')
|
||||
result, _ = run_effect('ascii_fx_zone', frame, {
|
||||
'cols': 20,
|
||||
'char_hue': expr
|
||||
}, {})
|
||||
assert result.shape == frame.shape
|
||||
print(" Zone-lum expression: OK")
|
||||
|
||||
# Test 3: With multiple expressions
|
||||
scale_expr = parse('(+ 0.5 (* zone-lum 0.5))')
|
||||
rot_expr = parse('(* zone-row-norm 30)')
|
||||
result, _ = run_effect('ascii_fx_zone', frame, {
|
||||
'cols': 20,
|
||||
'char_scale': scale_expr,
|
||||
'char_rotation': rot_expr
|
||||
}, {})
|
||||
assert result.shape == frame.shape
|
||||
print(" Multiple expressions: OK")
|
||||
|
||||
# Test 4: With numeric literals
|
||||
result, _ = run_effect('ascii_fx_zone', frame, {
|
||||
'cols': 20,
|
||||
'char_hue': 90,
|
||||
'char_scale': 1.2
|
||||
}, {})
|
||||
assert result.shape == frame.shape
|
||||
print(" Numeric literals: OK")
|
||||
|
||||
# Test 5: Zone position expressions
|
||||
col_expr = parse('(* zone-col-norm 360)')
|
||||
result, _ = run_effect('ascii_fx_zone', frame, {
|
||||
'cols': 20,
|
||||
'char_hue': col_expr
|
||||
}, {})
|
||||
assert result.shape == frame.shape
|
||||
print(" Zone position expression: OK")
|
||||
|
||||
print(" ascii_fx_zone OK")
|
||||
|
||||
|
||||
def main():
|
||||
print("=" * 60)
|
||||
print("S-Expression Effect Interpreter Tests")
|
||||
print("=" * 60)
|
||||
|
||||
test_parser()
|
||||
test_interpreter_basics()
|
||||
test_primitives()
|
||||
test_effect_loading()
|
||||
test_ascii_fx_zone()
|
||||
passed, failed = test_effect_execution()
|
||||
|
||||
print("=" * 60)
|
||||
if not failed:
|
||||
print("All tests passed!")
|
||||
else:
|
||||
print(f"Tests completed with {len(failed)} failures")
|
||||
print("=" * 60)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
715
l1/sexp_effects/wgsl_compiler.py
Normal file
715
l1/sexp_effects/wgsl_compiler.py
Normal file
@@ -0,0 +1,715 @@
|
||||
"""
|
||||
S-Expression to WGSL Compiler
|
||||
|
||||
Compiles sexp effect definitions to WGSL compute shaders for GPU execution.
|
||||
The compilation happens at effect upload time (AOT), not at runtime.
|
||||
|
||||
Architecture:
|
||||
- Parse sexp AST
|
||||
- Analyze primitives used
|
||||
- Generate WGSL compute shader
|
||||
|
||||
Shader Categories:
|
||||
1. Per-pixel ops: brightness, invert, grayscale, sepia (1 thread per pixel)
|
||||
2. Geometric transforms: rotate, scale, wave, ripple (coordinate remap + sample)
|
||||
3. Neighborhood ops: blur, sharpen, edge detect (sample neighbors)
|
||||
"""
|
||||
|
||||
from typing import Dict, List, Any, Optional, Tuple, Set
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
import math
|
||||
|
||||
from .parser import parse, parse_all, Symbol, Keyword
|
||||
|
||||
|
||||
@dataclass
|
||||
class WGSLParam:
|
||||
"""A shader parameter (uniform)."""
|
||||
name: str
|
||||
wgsl_type: str # f32, i32, u32, vec2f, etc.
|
||||
default: Any
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompiledEffect:
|
||||
"""Result of compiling an sexp effect to WGSL."""
|
||||
name: str
|
||||
wgsl_code: str
|
||||
params: List[WGSLParam]
|
||||
workgroup_size: Tuple[int, int, int] = (16, 16, 1)
|
||||
# Metadata for runtime
|
||||
uses_time: bool = False
|
||||
uses_sampling: bool = False # Needs texture sampler
|
||||
category: str = "per_pixel" # per_pixel, geometric, neighborhood
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompilerContext:
|
||||
"""Context during compilation."""
|
||||
effect_name: str = ""
|
||||
params: Dict[str, WGSLParam] = field(default_factory=dict)
|
||||
locals: Dict[str, str] = field(default_factory=dict) # local var -> wgsl expr
|
||||
required_libs: Set[str] = field(default_factory=set)
|
||||
uses_time: bool = False
|
||||
uses_sampling: bool = False
|
||||
temp_counter: int = 0
|
||||
|
||||
def fresh_temp(self) -> str:
|
||||
"""Generate a fresh temporary variable name."""
|
||||
self.temp_counter += 1
|
||||
return f"_t{self.temp_counter}"
|
||||
|
||||
|
||||
class SexpToWGSLCompiler:
|
||||
"""
|
||||
Compiles S-expression effect definitions to WGSL compute shaders.
|
||||
"""
|
||||
|
||||
# Map sexp types to WGSL types
|
||||
TYPE_MAP = {
|
||||
'int': 'i32',
|
||||
'float': 'f32',
|
||||
'bool': 'u32', # WGSL doesn't have bool in storage
|
||||
'string': None, # Strings handled specially
|
||||
}
|
||||
|
||||
# Per-pixel primitives that can be compiled directly
|
||||
PER_PIXEL_PRIMITIVES = {
|
||||
'color_ops:invert-img',
|
||||
'color_ops:grayscale',
|
||||
'color_ops:sepia',
|
||||
'color_ops:adjust',
|
||||
'color_ops:adjust-brightness',
|
||||
'color_ops:shift-hsv',
|
||||
'color_ops:quantize',
|
||||
}
|
||||
|
||||
# Geometric primitives (coordinate remapping)
|
||||
GEOMETRIC_PRIMITIVES = {
|
||||
'geometry:scale-img',
|
||||
'geometry:rotate-img',
|
||||
'geometry:translate',
|
||||
'geometry:flip-h',
|
||||
'geometry:flip-v',
|
||||
'geometry:remap',
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
self.ctx: Optional[CompilerContext] = None
|
||||
|
||||
def compile_file(self, path: str) -> CompiledEffect:
|
||||
"""Compile an effect from a .sexp file."""
|
||||
with open(path, 'r') as f:
|
||||
content = f.read()
|
||||
exprs = parse_all(content)
|
||||
return self.compile(exprs)
|
||||
|
||||
def compile_string(self, sexp_code: str) -> CompiledEffect:
|
||||
"""Compile an effect from an sexp string."""
|
||||
exprs = parse_all(sexp_code)
|
||||
return self.compile(exprs)
|
||||
|
||||
def compile(self, expr: Any) -> CompiledEffect:
|
||||
"""Compile a parsed sexp expression."""
|
||||
self.ctx = CompilerContext()
|
||||
|
||||
# Handle multiple top-level expressions (require-primitives, define-effect)
|
||||
if isinstance(expr, list) and expr and isinstance(expr[0], list):
|
||||
for e in expr:
|
||||
self._process_toplevel(e)
|
||||
else:
|
||||
self._process_toplevel(expr)
|
||||
|
||||
# Generate the WGSL shader
|
||||
wgsl = self._generate_wgsl()
|
||||
|
||||
# Determine category based on primitives used
|
||||
category = self._determine_category()
|
||||
|
||||
return CompiledEffect(
|
||||
name=self.ctx.effect_name,
|
||||
wgsl_code=wgsl,
|
||||
params=list(self.ctx.params.values()),
|
||||
uses_time=self.ctx.uses_time,
|
||||
uses_sampling=self.ctx.uses_sampling,
|
||||
category=category,
|
||||
)
|
||||
|
||||
def _process_toplevel(self, expr: Any):
|
||||
"""Process a top-level expression."""
|
||||
if not isinstance(expr, list) or not expr:
|
||||
return
|
||||
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol):
|
||||
if head.name == 'require-primitives':
|
||||
# Track required primitive libraries
|
||||
for lib in expr[1:]:
|
||||
lib_name = lib.name if isinstance(lib, Symbol) else str(lib)
|
||||
self.ctx.required_libs.add(lib_name)
|
||||
|
||||
elif head.name == 'define-effect':
|
||||
self._compile_effect_def(expr)
|
||||
|
||||
def _compile_effect_def(self, expr: list):
|
||||
"""Compile a define-effect form."""
|
||||
# (define-effect name :params (...) body)
|
||||
self.ctx.effect_name = expr[1].name if isinstance(expr[1], Symbol) else str(expr[1])
|
||||
|
||||
# Parse :params and body
|
||||
i = 2
|
||||
body = None
|
||||
while i < len(expr):
|
||||
item = expr[i]
|
||||
if isinstance(item, Keyword) and item.name == 'params':
|
||||
self._parse_params(expr[i + 1])
|
||||
i += 2
|
||||
elif isinstance(item, Keyword):
|
||||
i += 2 # Skip other keywords
|
||||
else:
|
||||
body = item
|
||||
i += 1
|
||||
|
||||
if body:
|
||||
self.ctx.body_expr = body
|
||||
|
||||
def _parse_params(self, params_list: list):
|
||||
"""Parse the :params block."""
|
||||
for param_def in params_list:
|
||||
if not isinstance(param_def, list):
|
||||
continue
|
||||
|
||||
name = param_def[0].name if isinstance(param_def[0], Symbol) else str(param_def[0])
|
||||
|
||||
# Parse keyword args
|
||||
param_type = 'float'
|
||||
default = 0
|
||||
|
||||
i = 1
|
||||
while i < len(param_def):
|
||||
item = param_def[i]
|
||||
if isinstance(item, Keyword):
|
||||
if i + 1 < len(param_def):
|
||||
val = param_def[i + 1]
|
||||
if item.name == 'type':
|
||||
param_type = val.name if isinstance(val, Symbol) else str(val)
|
||||
elif item.name == 'default':
|
||||
default = val
|
||||
i += 2
|
||||
else:
|
||||
i += 1
|
||||
|
||||
wgsl_type = self.TYPE_MAP.get(param_type, 'f32')
|
||||
if wgsl_type:
|
||||
self.ctx.params[name] = WGSLParam(name, wgsl_type, default)
|
||||
|
||||
def _determine_category(self) -> str:
|
||||
"""Determine shader category based on primitives used."""
|
||||
for lib in self.ctx.required_libs:
|
||||
if lib == 'geometry':
|
||||
return 'geometric'
|
||||
if lib == 'filters':
|
||||
return 'neighborhood'
|
||||
return 'per_pixel'
|
||||
|
||||
def _generate_wgsl(self) -> str:
|
||||
"""Generate the complete WGSL shader code."""
|
||||
lines = []
|
||||
|
||||
# Header comment
|
||||
lines.append(f"// WGSL Shader: {self.ctx.effect_name}")
|
||||
lines.append(f"// Auto-generated from sexp effect definition")
|
||||
lines.append("")
|
||||
|
||||
# Bindings
|
||||
lines.append("@group(0) @binding(0) var<storage, read> input: array<u32>;")
|
||||
lines.append("@group(0) @binding(1) var<storage, read_write> output: array<u32>;")
|
||||
lines.append("")
|
||||
|
||||
# Params struct
|
||||
if self.ctx.params:
|
||||
lines.append("struct Params {")
|
||||
lines.append(" width: u32,")
|
||||
lines.append(" height: u32,")
|
||||
lines.append(" time: f32,")
|
||||
for param in self.ctx.params.values():
|
||||
lines.append(f" {param.name}: {param.wgsl_type},")
|
||||
lines.append("}")
|
||||
lines.append("@group(0) @binding(2) var<uniform> params: Params;")
|
||||
else:
|
||||
lines.append("struct Params {")
|
||||
lines.append(" width: u32,")
|
||||
lines.append(" height: u32,")
|
||||
lines.append(" time: f32,")
|
||||
lines.append("}")
|
||||
lines.append("@group(0) @binding(2) var<uniform> params: Params;")
|
||||
lines.append("")
|
||||
|
||||
# Helper functions
|
||||
lines.extend(self._generate_helpers())
|
||||
lines.append("")
|
||||
|
||||
# Main compute shader
|
||||
lines.append("@compute @workgroup_size(16, 16, 1)")
|
||||
lines.append("fn main(@builtin(global_invocation_id) gid: vec3<u32>) {")
|
||||
lines.append(" let x = gid.x;")
|
||||
lines.append(" let y = gid.y;")
|
||||
lines.append(" if (x >= params.width || y >= params.height) { return; }")
|
||||
lines.append(" let idx = y * params.width + x;")
|
||||
lines.append("")
|
||||
|
||||
# Compile the effect body
|
||||
body_code = self._compile_expr(self.ctx.body_expr)
|
||||
lines.append(f" // Effect: {self.ctx.effect_name}")
|
||||
lines.append(body_code)
|
||||
lines.append("}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _generate_helpers(self) -> List[str]:
|
||||
"""Generate WGSL helper functions."""
|
||||
helpers = []
|
||||
|
||||
# Pack/unpack RGB from u32
|
||||
helpers.append("fn unpack_rgb(packed: u32) -> vec3<f32> {")
|
||||
helpers.append(" let r = f32((packed >> 16u) & 0xFFu) / 255.0;")
|
||||
helpers.append(" let g = f32((packed >> 8u) & 0xFFu) / 255.0;")
|
||||
helpers.append(" let b = f32(packed & 0xFFu) / 255.0;")
|
||||
helpers.append(" return vec3<f32>(r, g, b);")
|
||||
helpers.append("}")
|
||||
helpers.append("")
|
||||
|
||||
helpers.append("fn pack_rgb(rgb: vec3<f32>) -> u32 {")
|
||||
helpers.append(" let r = u32(clamp(rgb.r, 0.0, 1.0) * 255.0);")
|
||||
helpers.append(" let g = u32(clamp(rgb.g, 0.0, 1.0) * 255.0);")
|
||||
helpers.append(" let b = u32(clamp(rgb.b, 0.0, 1.0) * 255.0);")
|
||||
helpers.append(" return (r << 16u) | (g << 8u) | b;")
|
||||
helpers.append("}")
|
||||
helpers.append("")
|
||||
|
||||
# Bilinear sampling for geometric transforms
|
||||
if self.ctx.uses_sampling or 'geometry' in self.ctx.required_libs:
|
||||
helpers.append("fn sample_bilinear(sx: f32, sy: f32) -> vec3<f32> {")
|
||||
helpers.append(" let w = f32(params.width);")
|
||||
helpers.append(" let h = f32(params.height);")
|
||||
helpers.append(" let cx = clamp(sx, 0.0, w - 1.001);")
|
||||
helpers.append(" let cy = clamp(sy, 0.0, h - 1.001);")
|
||||
helpers.append(" let x0 = u32(cx);")
|
||||
helpers.append(" let y0 = u32(cy);")
|
||||
helpers.append(" let x1 = min(x0 + 1u, params.width - 1u);")
|
||||
helpers.append(" let y1 = min(y0 + 1u, params.height - 1u);")
|
||||
helpers.append(" let fx = cx - f32(x0);")
|
||||
helpers.append(" let fy = cy - f32(y0);")
|
||||
helpers.append(" let c00 = unpack_rgb(input[y0 * params.width + x0]);")
|
||||
helpers.append(" let c10 = unpack_rgb(input[y0 * params.width + x1]);")
|
||||
helpers.append(" let c01 = unpack_rgb(input[y1 * params.width + x0]);")
|
||||
helpers.append(" let c11 = unpack_rgb(input[y1 * params.width + x1]);")
|
||||
helpers.append(" let top = mix(c00, c10, fx);")
|
||||
helpers.append(" let bot = mix(c01, c11, fx);")
|
||||
helpers.append(" return mix(top, bot, fy);")
|
||||
helpers.append("}")
|
||||
helpers.append("")
|
||||
|
||||
# HSV conversion for color effects
|
||||
if 'color_ops' in self.ctx.required_libs or 'color' in self.ctx.required_libs:
|
||||
helpers.append("fn rgb_to_hsv(rgb: vec3<f32>) -> vec3<f32> {")
|
||||
helpers.append(" let mx = max(max(rgb.r, rgb.g), rgb.b);")
|
||||
helpers.append(" let mn = min(min(rgb.r, rgb.g), rgb.b);")
|
||||
helpers.append(" let d = mx - mn;")
|
||||
helpers.append(" var h = 0.0;")
|
||||
helpers.append(" if (d > 0.0) {")
|
||||
helpers.append(" if (mx == rgb.r) { h = (rgb.g - rgb.b) / d; }")
|
||||
helpers.append(" else if (mx == rgb.g) { h = 2.0 + (rgb.b - rgb.r) / d; }")
|
||||
helpers.append(" else { h = 4.0 + (rgb.r - rgb.g) / d; }")
|
||||
helpers.append(" h = h / 6.0;")
|
||||
helpers.append(" if (h < 0.0) { h = h + 1.0; }")
|
||||
helpers.append(" }")
|
||||
helpers.append(" let s = select(0.0, d / mx, mx > 0.0);")
|
||||
helpers.append(" return vec3<f32>(h, s, mx);")
|
||||
helpers.append("}")
|
||||
helpers.append("")
|
||||
|
||||
helpers.append("fn hsv_to_rgb(hsv: vec3<f32>) -> vec3<f32> {")
|
||||
helpers.append(" let h = hsv.x * 6.0;")
|
||||
helpers.append(" let s = hsv.y;")
|
||||
helpers.append(" let v = hsv.z;")
|
||||
helpers.append(" let c = v * s;")
|
||||
helpers.append(" let x = c * (1.0 - abs(h % 2.0 - 1.0));")
|
||||
helpers.append(" let m = v - c;")
|
||||
helpers.append(" var rgb: vec3<f32>;")
|
||||
helpers.append(" if (h < 1.0) { rgb = vec3<f32>(c, x, 0.0); }")
|
||||
helpers.append(" else if (h < 2.0) { rgb = vec3<f32>(x, c, 0.0); }")
|
||||
helpers.append(" else if (h < 3.0) { rgb = vec3<f32>(0.0, c, x); }")
|
||||
helpers.append(" else if (h < 4.0) { rgb = vec3<f32>(0.0, x, c); }")
|
||||
helpers.append(" else if (h < 5.0) { rgb = vec3<f32>(x, 0.0, c); }")
|
||||
helpers.append(" else { rgb = vec3<f32>(c, 0.0, x); }")
|
||||
helpers.append(" return rgb + vec3<f32>(m, m, m);")
|
||||
helpers.append("}")
|
||||
helpers.append("")
|
||||
|
||||
return helpers
|
||||
|
||||
def _compile_expr(self, expr: Any, indent: int = 4) -> str:
|
||||
"""Compile an sexp expression to WGSL code."""
|
||||
ind = " " * indent
|
||||
|
||||
# Literals
|
||||
if isinstance(expr, (int, float)):
|
||||
return f"{ind}// literal: {expr}"
|
||||
|
||||
if isinstance(expr, str):
|
||||
return f'{ind}// string: "{expr}"'
|
||||
|
||||
# Symbol reference
|
||||
if isinstance(expr, Symbol):
|
||||
name = expr.name
|
||||
if name == 'frame':
|
||||
return f"{ind}let rgb = unpack_rgb(input[idx]);"
|
||||
if name == 't' or name == '_time':
|
||||
self.ctx.uses_time = True
|
||||
return f"{ind}let t = params.time;"
|
||||
if name in self.ctx.params:
|
||||
return f"{ind}let {name} = params.{name};"
|
||||
if name in self.ctx.locals:
|
||||
return f"{ind}// local: {name}"
|
||||
return f"{ind}// unknown symbol: {name}"
|
||||
|
||||
# List (function call or special form)
|
||||
if isinstance(expr, list) and expr:
|
||||
head = expr[0]
|
||||
|
||||
if isinstance(head, Symbol):
|
||||
form = head.name
|
||||
|
||||
# Special forms
|
||||
if form == 'let' or form == 'let*':
|
||||
return self._compile_let(expr, indent)
|
||||
|
||||
if form == 'if':
|
||||
return self._compile_if(expr, indent)
|
||||
|
||||
if form == 'or':
|
||||
# (or a b) - return a if truthy, else b
|
||||
return self._compile_or(expr, indent)
|
||||
|
||||
# Primitive calls
|
||||
if ':' in form:
|
||||
return self._compile_primitive_call(expr, indent)
|
||||
|
||||
# Arithmetic
|
||||
if form in ('+', '-', '*', '/'):
|
||||
return self._compile_arithmetic(expr, indent)
|
||||
|
||||
if form in ('>', '<', '>=', '<=', '='):
|
||||
return self._compile_comparison(expr, indent)
|
||||
|
||||
if form == 'max':
|
||||
return self._compile_builtin('max', expr[1:], indent)
|
||||
|
||||
if form == 'min':
|
||||
return self._compile_builtin('min', expr[1:], indent)
|
||||
|
||||
return f"{ind}// unhandled: {expr}"
|
||||
|
||||
def _compile_let(self, expr: list, indent: int) -> str:
|
||||
"""Compile let/let* binding form."""
|
||||
ind = " " * indent
|
||||
lines = []
|
||||
|
||||
bindings = expr[1]
|
||||
body = expr[2]
|
||||
|
||||
# Parse bindings (Clojure style: [x 1 y 2] or Scheme style: ((x 1) (y 2)))
|
||||
pairs = []
|
||||
if bindings and isinstance(bindings[0], Symbol):
|
||||
# Clojure style
|
||||
i = 0
|
||||
while i < len(bindings) - 1:
|
||||
name = bindings[i].name if isinstance(bindings[i], Symbol) else str(bindings[i])
|
||||
value = bindings[i + 1]
|
||||
pairs.append((name, value))
|
||||
i += 2
|
||||
else:
|
||||
# Scheme style
|
||||
for binding in bindings:
|
||||
name = binding[0].name if isinstance(binding[0], Symbol) else str(binding[0])
|
||||
value = binding[1]
|
||||
pairs.append((name, value))
|
||||
|
||||
# Compile bindings
|
||||
for name, value in pairs:
|
||||
val_code = self._expr_to_wgsl(value)
|
||||
lines.append(f"{ind}let {name} = {val_code};")
|
||||
self.ctx.locals[name] = val_code
|
||||
|
||||
# Compile body
|
||||
body_lines = self._compile_body(body, indent)
|
||||
lines.append(body_lines)
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _compile_body(self, body: Any, indent: int) -> str:
|
||||
"""Compile the body of an effect (the final image expression)."""
|
||||
ind = " " * indent
|
||||
|
||||
# Most effects end with a primitive call that produces the output
|
||||
if isinstance(body, list) and body:
|
||||
head = body[0]
|
||||
if isinstance(head, Symbol) and ':' in head.name:
|
||||
return self._compile_primitive_call(body, indent)
|
||||
|
||||
# If body is just 'frame', pass through
|
||||
if isinstance(body, Symbol) and body.name == 'frame':
|
||||
return f"{ind}output[idx] = input[idx];"
|
||||
|
||||
return f"{ind}// body: {body}"
|
||||
|
||||
def _compile_primitive_call(self, expr: list, indent: int) -> str:
|
||||
"""Compile a primitive function call."""
|
||||
ind = " " * indent
|
||||
head = expr[0]
|
||||
prim_name = head.name if isinstance(head, Symbol) else str(head)
|
||||
args = expr[1:]
|
||||
|
||||
# Per-pixel color operations
|
||||
if prim_name == 'color_ops:invert-img':
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let result = vec3<f32>(1.0, 1.0, 1.0) - rgb;
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'color_ops:grayscale':
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let gray = 0.299 * rgb.r + 0.587 * rgb.g + 0.114 * rgb.b;
|
||||
{ind}let result = vec3<f32>(gray, gray, gray);
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'color_ops:adjust-brightness':
|
||||
amount = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let adj = f32({amount}) / 255.0;
|
||||
{ind}let result = clamp(rgb + vec3<f32>(adj, adj, adj), vec3<f32>(0.0, 0.0, 0.0), vec3<f32>(1.0, 1.0, 1.0));
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'color_ops:adjust':
|
||||
# (adjust img brightness contrast)
|
||||
brightness = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
|
||||
contrast = self._expr_to_wgsl(args[2]) if len(args) > 2 else "1.0"
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let centered = rgb - vec3<f32>(0.5, 0.5, 0.5);
|
||||
{ind}let contrasted = centered * {contrast};
|
||||
{ind}let brightened = contrasted + vec3<f32>(0.5, 0.5, 0.5) + vec3<f32>({brightness}/255.0);
|
||||
{ind}let result = clamp(brightened, vec3<f32>(0.0), vec3<f32>(1.0));
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'color_ops:sepia':
|
||||
intensity = self._expr_to_wgsl(args[1]) if len(args) > 1 else "1.0"
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let sepia_r = 0.393 * rgb.r + 0.769 * rgb.g + 0.189 * rgb.b;
|
||||
{ind}let sepia_g = 0.349 * rgb.r + 0.686 * rgb.g + 0.168 * rgb.b;
|
||||
{ind}let sepia_b = 0.272 * rgb.r + 0.534 * rgb.g + 0.131 * rgb.b;
|
||||
{ind}let sepia = vec3<f32>(sepia_r, sepia_g, sepia_b);
|
||||
{ind}let result = mix(rgb, sepia, {intensity});
|
||||
{ind}output[idx] = pack_rgb(clamp(result, vec3<f32>(0.0), vec3<f32>(1.0)));"""
|
||||
|
||||
if prim_name == 'color_ops:shift-hsv':
|
||||
h_shift = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
|
||||
s_mult = self._expr_to_wgsl(args[2]) if len(args) > 2 else "1.0"
|
||||
v_mult = self._expr_to_wgsl(args[3]) if len(args) > 3 else "1.0"
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}var hsv = rgb_to_hsv(rgb);
|
||||
{ind}hsv.x = fract(hsv.x + {h_shift} / 360.0);
|
||||
{ind}hsv.y = clamp(hsv.y * {s_mult}, 0.0, 1.0);
|
||||
{ind}hsv.z = clamp(hsv.z * {v_mult}, 0.0, 1.0);
|
||||
{ind}let result = hsv_to_rgb(hsv);
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'color_ops:quantize':
|
||||
levels = self._expr_to_wgsl(args[1]) if len(args) > 1 else "8.0"
|
||||
return f"""{ind}let rgb = unpack_rgb(input[idx]);
|
||||
{ind}let lvl = max(2.0, {levels});
|
||||
{ind}let result = floor(rgb * lvl) / lvl;
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
# Geometric transforms
|
||||
if prim_name == 'geometry:scale-img':
|
||||
sx = self._expr_to_wgsl(args[1]) if len(args) > 1 else "1.0"
|
||||
sy = self._expr_to_wgsl(args[2]) if len(args) > 2 else sx
|
||||
self.ctx.uses_sampling = True
|
||||
return f"""{ind}let w = f32(params.width);
|
||||
{ind}let h = f32(params.height);
|
||||
{ind}let cx = w / 2.0;
|
||||
{ind}let cy = h / 2.0;
|
||||
{ind}let sx = f32(x) - cx;
|
||||
{ind}let sy = f32(y) - cy;
|
||||
{ind}let src_x = sx / {sx} + cx;
|
||||
{ind}let src_y = sy / {sy} + cy;
|
||||
{ind}let result = sample_bilinear(src_x, src_y);
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'geometry:rotate-img':
|
||||
angle = self._expr_to_wgsl(args[1]) if len(args) > 1 else "0.0"
|
||||
self.ctx.uses_sampling = True
|
||||
return f"""{ind}let w = f32(params.width);
|
||||
{ind}let h = f32(params.height);
|
||||
{ind}let cx = w / 2.0;
|
||||
{ind}let cy = h / 2.0;
|
||||
{ind}let angle_rad = {angle} * 3.14159265 / 180.0;
|
||||
{ind}let cos_a = cos(-angle_rad);
|
||||
{ind}let sin_a = sin(-angle_rad);
|
||||
{ind}let dx = f32(x) - cx;
|
||||
{ind}let dy = f32(y) - cy;
|
||||
{ind}let src_x = dx * cos_a - dy * sin_a + cx;
|
||||
{ind}let src_y = dx * sin_a + dy * cos_a + cy;
|
||||
{ind}let result = sample_bilinear(src_x, src_y);
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
if prim_name == 'geometry:flip-h':
|
||||
return f"""{ind}let src_idx = y * params.width + (params.width - 1u - x);
|
||||
{ind}output[idx] = input[src_idx];"""
|
||||
|
||||
if prim_name == 'geometry:flip-v':
|
||||
return f"""{ind}let src_idx = (params.height - 1u - y) * params.width + x;
|
||||
{ind}output[idx] = input[src_idx];"""
|
||||
|
||||
# Image library
|
||||
if prim_name == 'image:blur':
|
||||
radius = self._expr_to_wgsl(args[1]) if len(args) > 1 else "5"
|
||||
# Box blur approximation (separable would be better)
|
||||
return f"""{ind}let radius = i32({radius});
|
||||
{ind}var sum = vec3<f32>(0.0, 0.0, 0.0);
|
||||
{ind}var count = 0.0;
|
||||
{ind}for (var dy = -radius; dy <= radius; dy = dy + 1) {{
|
||||
{ind} for (var dx = -radius; dx <= radius; dx = dx + 1) {{
|
||||
{ind} let sx = i32(x) + dx;
|
||||
{ind} let sy = i32(y) + dy;
|
||||
{ind} if (sx >= 0 && sx < i32(params.width) && sy >= 0 && sy < i32(params.height)) {{
|
||||
{ind} let sidx = u32(sy) * params.width + u32(sx);
|
||||
{ind} sum = sum + unpack_rgb(input[sidx]);
|
||||
{ind} count = count + 1.0;
|
||||
{ind} }}
|
||||
{ind} }}
|
||||
{ind}}}
|
||||
{ind}let result = sum / count;
|
||||
{ind}output[idx] = pack_rgb(result);"""
|
||||
|
||||
# Fallback - passthrough
|
||||
return f"""{ind}// Unimplemented primitive: {prim_name}
|
||||
{ind}output[idx] = input[idx];"""
|
||||
|
||||
def _compile_if(self, expr: list, indent: int) -> str:
|
||||
"""Compile if expression."""
|
||||
ind = " " * indent
|
||||
cond = self._expr_to_wgsl(expr[1])
|
||||
then_expr = expr[2]
|
||||
else_expr = expr[3] if len(expr) > 3 else None
|
||||
|
||||
lines = []
|
||||
lines.append(f"{ind}if ({cond}) {{")
|
||||
lines.append(self._compile_body(then_expr, indent + 4))
|
||||
if else_expr:
|
||||
lines.append(f"{ind}}} else {{")
|
||||
lines.append(self._compile_body(else_expr, indent + 4))
|
||||
lines.append(f"{ind}}}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _compile_or(self, expr: list, indent: int) -> str:
|
||||
"""Compile or expression - returns first truthy value."""
|
||||
# For numeric context, (or a b) means "a if a != 0 else b"
|
||||
a = self._expr_to_wgsl(expr[1])
|
||||
b = self._expr_to_wgsl(expr[2]) if len(expr) > 2 else "0.0"
|
||||
return f"select({b}, {a}, {a} != 0.0)"
|
||||
|
||||
def _compile_arithmetic(self, expr: list, indent: int) -> str:
|
||||
"""Compile arithmetic expression to inline WGSL."""
|
||||
op = expr[0].name
|
||||
operands = [self._expr_to_wgsl(arg) for arg in expr[1:]]
|
||||
|
||||
if len(operands) == 1:
|
||||
if op == '-':
|
||||
return f"(-{operands[0]})"
|
||||
return operands[0]
|
||||
|
||||
return f"({f' {op} '.join(operands)})"
|
||||
|
||||
def _compile_comparison(self, expr: list, indent: int) -> str:
|
||||
"""Compile comparison expression."""
|
||||
op = expr[0].name
|
||||
if op == '=':
|
||||
op = '=='
|
||||
a = self._expr_to_wgsl(expr[1])
|
||||
b = self._expr_to_wgsl(expr[2])
|
||||
return f"({a} {op} {b})"
|
||||
|
||||
def _compile_builtin(self, fn: str, args: list, indent: int) -> str:
|
||||
"""Compile builtin function call."""
|
||||
compiled_args = [self._expr_to_wgsl(arg) for arg in args]
|
||||
return f"{fn}({', '.join(compiled_args)})"
|
||||
|
||||
def _expr_to_wgsl(self, expr: Any) -> str:
|
||||
"""Convert an expression to inline WGSL code."""
|
||||
if isinstance(expr, (int, float)):
|
||||
# Ensure floats have decimal point
|
||||
if isinstance(expr, float) or '.' not in str(expr):
|
||||
return f"{float(expr)}"
|
||||
return str(expr)
|
||||
|
||||
if isinstance(expr, str):
|
||||
return f'"{expr}"'
|
||||
|
||||
if isinstance(expr, Symbol):
|
||||
name = expr.name
|
||||
if name == 'frame':
|
||||
return "rgb" # Assume rgb is already loaded
|
||||
if name == 't' or name == '_time':
|
||||
self.ctx.uses_time = True
|
||||
return "params.time"
|
||||
if name == 'pi':
|
||||
return "3.14159265"
|
||||
if name in self.ctx.params:
|
||||
return f"params.{name}"
|
||||
if name in self.ctx.locals:
|
||||
return name
|
||||
return name
|
||||
|
||||
if isinstance(expr, list) and expr:
|
||||
head = expr[0]
|
||||
if isinstance(head, Symbol):
|
||||
form = head.name
|
||||
|
||||
# Arithmetic
|
||||
if form in ('+', '-', '*', '/'):
|
||||
return self._compile_arithmetic(expr, 0)
|
||||
|
||||
# Comparison
|
||||
if form in ('>', '<', '>=', '<=', '='):
|
||||
return self._compile_comparison(expr, 0)
|
||||
|
||||
# Builtins
|
||||
if form in ('max', 'min', 'abs', 'floor', 'ceil', 'sin', 'cos', 'sqrt'):
|
||||
args = [self._expr_to_wgsl(a) for a in expr[1:]]
|
||||
return f"{form}({', '.join(args)})"
|
||||
|
||||
if form == 'or':
|
||||
return self._compile_or(expr, 0)
|
||||
|
||||
# Image dimension queries
|
||||
if form == 'image:width':
|
||||
return "f32(params.width)"
|
||||
if form == 'image:height':
|
||||
return "f32(params.height)"
|
||||
|
||||
return f"/* unknown: {expr} */"
|
||||
|
||||
|
||||
def compile_effect(sexp_code: str) -> CompiledEffect:
|
||||
"""Convenience function to compile an sexp effect string."""
|
||||
compiler = SexpToWGSLCompiler()
|
||||
return compiler.compile_string(sexp_code)
|
||||
|
||||
|
||||
def compile_effect_file(path: str) -> CompiledEffect:
|
||||
"""Convenience function to compile an sexp effect file."""
|
||||
compiler = SexpToWGSLCompiler()
|
||||
return compiler.compile_file(path)
|
||||
Reference in New Issue
Block a user