Add modular primitive libraries and fix Symbol class compatibility
- Add primitive_libs/ with modular primitive loading (core, math, image, color, color_ops, filters, geometry, drawing, blending, arrays, ascii) - Effects now explicitly declare dependencies via (require-primitives "...") - Convert ascii-fx-zone from hardcoded special form to loadable primitive - Add _is_symbol/_is_keyword helpers for duck typing to support both sexp_effects.parser.Symbol and artdag.sexp.parser.Symbol classes - Auto-inject _interp and _env for primitives that need them - Remove silent error swallowing in cell_effect evaluation Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -6,6 +6,7 @@
|
|||||||
(recipe "ascii_alternating_rotate"
|
(recipe "ascii_alternating_rotate"
|
||||||
:version "1.0"
|
:version "1.0"
|
||||||
:description "ASCII art with alternating rotation directions per cell"
|
:description "ASCII art with alternating rotation directions per cell"
|
||||||
|
:minimal-primitives true
|
||||||
:encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30)
|
:encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30)
|
||||||
|
|
||||||
:params (
|
:params (
|
||||||
|
|||||||
17
execute.py
17
execute.py
@@ -348,9 +348,9 @@ def get_encoding(recipe_encoding: dict, step_config: dict) -> dict:
|
|||||||
class SexpEffectModule:
|
class SexpEffectModule:
|
||||||
"""Wrapper for S-expression effects to provide process_frame interface."""
|
"""Wrapper for S-expression effects to provide process_frame interface."""
|
||||||
|
|
||||||
def __init__(self, effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
|
def __init__(self, effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None, minimal_primitives: bool = False):
|
||||||
from sexp_effects import get_interpreter
|
from sexp_effects import get_interpreter
|
||||||
self.interp = get_interpreter()
|
self.interp = get_interpreter(minimal_primitives=minimal_primitives)
|
||||||
|
|
||||||
# Load only explicitly declared effects from the recipe's registry
|
# Load only explicitly declared effects from the recipe's registry
|
||||||
# No auto-loading from directory - everything must be explicit
|
# No auto-loading from directory - everything must be explicit
|
||||||
@@ -371,10 +371,10 @@ class SexpEffectModule:
|
|||||||
return self.interp.run_effect(self.effect_name, frame, params, state or {})
|
return self.interp.run_effect(self.effect_name, frame, params, state or {})
|
||||||
|
|
||||||
|
|
||||||
def load_effect(effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None):
|
def load_effect(effect_path: Path, effects_registry: dict = None, recipe_dir: Path = None, minimal_primitives: bool = False):
|
||||||
"""Load an effect module from a local path (.py or .sexp)."""
|
"""Load an effect module from a local path (.py or .sexp)."""
|
||||||
if effect_path.suffix == ".sexp":
|
if effect_path.suffix == ".sexp":
|
||||||
return SexpEffectModule(effect_path, effects_registry, recipe_dir)
|
return SexpEffectModule(effect_path, effects_registry, recipe_dir, minimal_primitives)
|
||||||
|
|
||||||
spec = importlib.util.spec_from_file_location("effect", effect_path)
|
spec = importlib.util.spec_from_file_location("effect", effect_path)
|
||||||
module = importlib.util.module_from_spec(spec)
|
module = importlib.util.module_from_spec(spec)
|
||||||
@@ -981,6 +981,11 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
|||||||
if effects_registry:
|
if effects_registry:
|
||||||
print(f"Effects registry: {list(effects_registry.keys())}", file=sys.stderr)
|
print(f"Effects registry: {list(effects_registry.keys())}", file=sys.stderr)
|
||||||
|
|
||||||
|
# Check for minimal primitives mode
|
||||||
|
minimal_primitives = plan.get("minimal_primitives", False)
|
||||||
|
if minimal_primitives:
|
||||||
|
print(f"Minimal primitives mode: enabled", file=sys.stderr)
|
||||||
|
|
||||||
# Execute steps
|
# Execute steps
|
||||||
results = {} # step_id -> output_path
|
results = {} # step_id -> output_path
|
||||||
work_dir = Path(tempfile.mkdtemp(prefix="artdag_exec_"))
|
work_dir = Path(tempfile.mkdtemp(prefix="artdag_exec_"))
|
||||||
@@ -1124,7 +1129,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
|||||||
|
|
||||||
if effect_path:
|
if effect_path:
|
||||||
full_path = recipe_dir / effect_path
|
full_path = recipe_dir / effect_path
|
||||||
effect_module = load_effect(full_path, effects_registry, recipe_dir)
|
effect_module = load_effect(full_path, effects_registry, recipe_dir, minimal_primitives)
|
||||||
params = {k: v for k, v in config.items()
|
params = {k: v for k, v in config.items()
|
||||||
if k not in ("effect", "effect_path", "cid", "encoding", "multi_input")}
|
if k not in ("effect", "effect_path", "cid", "encoding", "multi_input")}
|
||||||
print(f" Effect: {effect_name}", file=sys.stderr)
|
print(f" Effect: {effect_name}", file=sys.stderr)
|
||||||
@@ -1420,7 +1425,7 @@ def execute_plan(plan_path: Path = None, output_path: Path = None, recipe_dir: P
|
|||||||
|
|
||||||
if effect_path:
|
if effect_path:
|
||||||
full_path = recipe_dir / effect_path
|
full_path = recipe_dir / effect_path
|
||||||
effect_module = load_effect(full_path, effects_registry, recipe_dir)
|
effect_module = load_effect(full_path, effects_registry, recipe_dir, minimal_primitives)
|
||||||
params = {k: v for k, v in effect_config.items()
|
params = {k: v for k, v in effect_config.items()
|
||||||
if k not in ("effect", "effect_path", "cid", "encoding", "type")}
|
if k not in ("effect", "effect_path", "cid", "encoding", "type")}
|
||||||
print(f" COMPOUND [{i+1}/{len(effects)}]: {effect_name} (Python)", file=sys.stderr)
|
print(f" COMPOUND [{i+1}/{len(effects)}]: {effect_name} (Python)", file=sys.stderr)
|
||||||
|
|||||||
@@ -118,6 +118,7 @@ def run_staged_recipe(
|
|||||||
"output_step_id": plan.output_step_id,
|
"output_step_id": plan.output_step_id,
|
||||||
"analysis": analysis_data,
|
"analysis": analysis_data,
|
||||||
"effects_registry": plan.effects_registry,
|
"effects_registry": plan.effects_registry,
|
||||||
|
"minimal_primitives": plan.minimal_primitives,
|
||||||
"steps": [],
|
"steps": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
;; Composable ASCII Art with Per-Zone Expression-Driven Effects
|
;; 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:
|
;; Two modes of operation:
|
||||||
;;
|
;;
|
||||||
;; 1. EXPRESSION MODE: Use zone-* variables in expression parameters
|
;; 1. EXPRESSION MODE: Use zone-* variables in expression parameters
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
;; Blur effect - gaussian blur
|
;; Blur effect - gaussian blur
|
||||||
|
|
||||||
|
(require-primitives "filters" "math")
|
||||||
|
|
||||||
(define-effect blur
|
(define-effect blur
|
||||||
:params (
|
:params (
|
||||||
(radius :type int :default 5 :range [1 50])
|
(radius :type int :default 5 :range [1 50])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
;; Brightness effect - adjusts overall brightness
|
;; Brightness effect - adjusts overall brightness
|
||||||
;; Uses vectorized adjust primitive for fast processing
|
;; Uses vectorized adjust primitive for fast processing
|
||||||
|
|
||||||
|
(require-primitives "color_ops")
|
||||||
|
|
||||||
(define-effect brightness
|
(define-effect brightness
|
||||||
:params (
|
:params (
|
||||||
(amount :type int :default 0 :range [-255 255])
|
(amount :type int :default 0 :range [-255 255])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
;; Contrast effect - adjusts image contrast
|
;; Contrast effect - adjusts image contrast
|
||||||
;; Uses vectorized adjust primitive for fast processing
|
;; Uses vectorized adjust primitive for fast processing
|
||||||
|
|
||||||
|
(require-primitives "color_ops")
|
||||||
|
|
||||||
(define-effect contrast
|
(define-effect contrast
|
||||||
:params (
|
:params (
|
||||||
(amount :type int :default 1 :range [0.5 3])
|
(amount :type int :default 1 :range [0.5 3])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
;; Hue shift effect - rotates hue values
|
;; Hue shift effect - rotates hue values
|
||||||
;; Uses vectorized shift-hsv primitive for fast processing
|
;; Uses vectorized shift-hsv primitive for fast processing
|
||||||
|
|
||||||
|
(require-primitives "color_ops")
|
||||||
|
|
||||||
(define-effect hue_shift
|
(define-effect hue_shift
|
||||||
:params (
|
:params (
|
||||||
(degrees :type int :default 0 :range [0 360])
|
(degrees :type int :default 0 :range [0 360])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
;; Invert effect - inverts all colors
|
;; Invert effect - inverts all colors
|
||||||
;; Uses vectorized invert-img primitive for fast processing
|
;; Uses vectorized invert-img primitive for fast processing
|
||||||
|
|
||||||
|
(require-primitives "color_ops")
|
||||||
|
|
||||||
(define-effect invert
|
(define-effect invert
|
||||||
:params ()
|
:params ()
|
||||||
(invert-img frame))
|
(invert-img frame))
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
;; Rotate effect - rotates image
|
;; Rotate effect - rotates image
|
||||||
|
|
||||||
|
(require-primitives "geometry")
|
||||||
|
|
||||||
(define-effect rotate
|
(define-effect rotate
|
||||||
:params (
|
:params (
|
||||||
(angle :type int :default 0 :range [-360 360])
|
(angle :type int :default 0 :range [-360 360])
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
;; Saturation effect - adjusts color saturation
|
;; Saturation effect - adjusts color saturation
|
||||||
;; Uses vectorized shift-hsv primitive for fast processing
|
;; Uses vectorized shift-hsv primitive for fast processing
|
||||||
|
|
||||||
|
(require-primitives "color_ops")
|
||||||
|
|
||||||
(define-effect saturation
|
(define-effect saturation
|
||||||
:params (
|
:params (
|
||||||
(amount :type int :default 1 :range [0 3])
|
(amount :type int :default 1 :range [0 3])
|
||||||
|
|||||||
@@ -13,6 +13,21 @@ from .parser import Symbol, Keyword, parse, parse_file
|
|||||||
from .primitives import PRIMITIVES, reset_rng
|
from .primitives import PRIMITIVES, reset_rng
|
||||||
|
|
||||||
|
|
||||||
|
def _is_symbol(x) -> bool:
|
||||||
|
"""Check if x is a Symbol (duck typing to support multiple Symbol classes)."""
|
||||||
|
return hasattr(x, 'name') and type(x).__name__ == 'Symbol'
|
||||||
|
|
||||||
|
|
||||||
|
def _is_keyword(x) -> bool:
|
||||||
|
"""Check if x is a Keyword (duck typing to support multiple Keyword classes)."""
|
||||||
|
return hasattr(x, 'name') and type(x).__name__ == 'Keyword'
|
||||||
|
|
||||||
|
|
||||||
|
def _symbol_name(x) -> str:
|
||||||
|
"""Get the name from a Symbol."""
|
||||||
|
return x.name if hasattr(x, 'name') else str(x)
|
||||||
|
|
||||||
|
|
||||||
class Environment:
|
class Environment:
|
||||||
"""Lexical environment for variable bindings."""
|
"""Lexical environment for variable bindings."""
|
||||||
|
|
||||||
@@ -68,15 +83,28 @@ class Interpreter:
|
|||||||
|
|
||||||
Provides a safe execution environment where only
|
Provides a safe execution environment where only
|
||||||
whitelisted primitives can be called.
|
whitelisted primitives can be called.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
minimal_primitives: If True, only load core primitives (arithmetic, comparison,
|
||||||
|
basic data access). Additional primitives must be loaded with
|
||||||
|
(require-primitives) or (with-primitives).
|
||||||
|
If False (default), load all legacy primitives for backward compatibility.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self, minimal_primitives: bool = False):
|
||||||
# Base environment with primitives
|
# Base environment with primitives
|
||||||
self.global_env = Environment()
|
self.global_env = Environment()
|
||||||
|
self.minimal_primitives = minimal_primitives
|
||||||
|
|
||||||
# Load primitives
|
if minimal_primitives:
|
||||||
for name, fn in PRIMITIVES.items():
|
# Load only core primitives
|
||||||
self.global_env.set(name, fn)
|
from .primitive_libs.core import PRIMITIVES as CORE_PRIMITIVES
|
||||||
|
for name, fn in CORE_PRIMITIVES.items():
|
||||||
|
self.global_env.set(name, fn)
|
||||||
|
else:
|
||||||
|
# Load all legacy primitives for backward compatibility
|
||||||
|
for name, fn in PRIMITIVES.items():
|
||||||
|
self.global_env.set(name, fn)
|
||||||
|
|
||||||
# Special values
|
# Special values
|
||||||
self.global_env.set('true', True)
|
self.global_env.set('true', True)
|
||||||
@@ -98,10 +126,12 @@ class Interpreter:
|
|||||||
if expr is None:
|
if expr is None:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
if isinstance(expr, Symbol):
|
# Handle Symbol (duck typing to support both sexp_effects.parser.Symbol and artdag.sexp.parser.Symbol)
|
||||||
|
if _is_symbol(expr):
|
||||||
return env.get(expr.name)
|
return env.get(expr.name)
|
||||||
|
|
||||||
if isinstance(expr, Keyword):
|
# Handle Keyword (duck typing)
|
||||||
|
if _is_keyword(expr):
|
||||||
return expr # Keywords evaluate to themselves
|
return expr # Keywords evaluate to themselves
|
||||||
|
|
||||||
if isinstance(expr, np.ndarray):
|
if isinstance(expr, np.ndarray):
|
||||||
@@ -115,7 +145,7 @@ class Interpreter:
|
|||||||
head = expr[0]
|
head = expr[0]
|
||||||
|
|
||||||
# Special forms
|
# Special forms
|
||||||
if isinstance(head, Symbol):
|
if _is_symbol(head):
|
||||||
form = head.name
|
form = head.name
|
||||||
|
|
||||||
# Quote
|
# Quote
|
||||||
@@ -125,7 +155,7 @@ class Interpreter:
|
|||||||
# Define
|
# Define
|
||||||
if form == 'define':
|
if form == 'define':
|
||||||
name = expr[1]
|
name = expr[1]
|
||||||
if isinstance(name, Symbol):
|
if _is_symbol(name):
|
||||||
value = self.eval(expr[2], env)
|
value = self.eval(expr[2], env)
|
||||||
self.global_env.set(name.name, value)
|
self.global_env.set(name.name, value)
|
||||||
return value
|
return value
|
||||||
@@ -138,7 +168,7 @@ class Interpreter:
|
|||||||
|
|
||||||
# Lambda
|
# Lambda
|
||||||
if form == 'lambda' or form == 'λ':
|
if form == 'lambda' or form == 'λ':
|
||||||
params = [p.name if isinstance(p, Symbol) else p for p in expr[1]]
|
params = [p.name if _is_symbol(p) else p for p in expr[1]]
|
||||||
body = expr[2]
|
body = expr[2]
|
||||||
return Lambda(params, body, env)
|
return Lambda(params, body, env)
|
||||||
|
|
||||||
@@ -205,7 +235,7 @@ class Interpreter:
|
|||||||
|
|
||||||
# Set! (mutation)
|
# Set! (mutation)
|
||||||
if form == 'set!':
|
if form == 'set!':
|
||||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
name = expr[1].name if _is_symbol(expr[1]) else expr[1]
|
||||||
value = self.eval(expr[2], env)
|
value = self.eval(expr[2], env)
|
||||||
# Find and update in appropriate scope
|
# Find and update in appropriate scope
|
||||||
scope = env
|
scope = env
|
||||||
@@ -220,7 +250,7 @@ class Interpreter:
|
|||||||
if form == 'state-get':
|
if form == 'state-get':
|
||||||
state = env.get('__state__')
|
state = env.get('__state__')
|
||||||
key = self.eval(expr[1], env)
|
key = self.eval(expr[1], env)
|
||||||
if isinstance(key, Symbol):
|
if _is_symbol(key):
|
||||||
key = key.name
|
key = key.name
|
||||||
default = self.eval(expr[2], env) if len(expr) > 2 else None
|
default = self.eval(expr[2], env) if len(expr) > 2 else None
|
||||||
return state.get(key, default)
|
return state.get(key, default)
|
||||||
@@ -228,7 +258,7 @@ class Interpreter:
|
|||||||
if form == 'state-set':
|
if form == 'state-set':
|
||||||
state = env.get('__state__')
|
state = env.get('__state__')
|
||||||
key = self.eval(expr[1], env)
|
key = self.eval(expr[1], env)
|
||||||
if isinstance(key, Symbol):
|
if _is_symbol(key):
|
||||||
key = key.name
|
key = key.name
|
||||||
value = self.eval(expr[2], env)
|
value = self.eval(expr[2], env)
|
||||||
state[key] = value
|
state[key] = value
|
||||||
@@ -238,6 +268,14 @@ class Interpreter:
|
|||||||
if form == 'ascii-fx-zone':
|
if form == 'ascii-fx-zone':
|
||||||
return self._eval_ascii_fx_zone(expr, env)
|
return self._eval_ascii_fx_zone(expr, env)
|
||||||
|
|
||||||
|
# with-primitives - load primitive library and scope to body
|
||||||
|
if form == 'with-primitives':
|
||||||
|
return self._eval_with_primitives(expr, env)
|
||||||
|
|
||||||
|
# require-primitives - load primitive library into current scope
|
||||||
|
if form == 'require-primitives':
|
||||||
|
return self._eval_require_primitives(expr, env)
|
||||||
|
|
||||||
# Function call
|
# Function call
|
||||||
fn = self.eval(head, env)
|
fn = self.eval(head, env)
|
||||||
args = [self.eval(arg, env) for arg in expr[1:]]
|
args = [self.eval(arg, env) for arg in expr[1:]]
|
||||||
@@ -247,7 +285,7 @@ class Interpreter:
|
|||||||
kw_args = {}
|
kw_args = {}
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(args):
|
while i < len(args):
|
||||||
if isinstance(args[i], Keyword):
|
if _is_keyword(args[i]):
|
||||||
kw_args[args[i].name] = args[i + 1] if i + 1 < len(args) else None
|
kw_args[args[i].name] = args[i + 1] if i + 1 < len(args) else None
|
||||||
i += 2
|
i += 2
|
||||||
else:
|
else:
|
||||||
@@ -291,6 +329,19 @@ class Interpreter:
|
|||||||
else:
|
else:
|
||||||
wrapped_args.append(arg)
|
wrapped_args.append(arg)
|
||||||
|
|
||||||
|
# Inject _interp and _env for primitives that need them
|
||||||
|
import inspect
|
||||||
|
try:
|
||||||
|
sig = inspect.signature(fn)
|
||||||
|
params = sig.parameters
|
||||||
|
if '_interp' in params and '_interp' not in kwargs:
|
||||||
|
kwargs['_interp'] = self
|
||||||
|
if '_env' in params and '_env' not in kwargs:
|
||||||
|
kwargs['_env'] = env
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# Some built-in functions don't have inspectable signatures
|
||||||
|
pass
|
||||||
|
|
||||||
# Primitive function
|
# Primitive function
|
||||||
if kwargs:
|
if kwargs:
|
||||||
return fn(*wrapped_args, **kwargs)
|
return fn(*wrapped_args, **kwargs)
|
||||||
@@ -309,12 +360,12 @@ class Interpreter:
|
|||||||
return []
|
return []
|
||||||
|
|
||||||
# Check if Clojure style (flat list with symbols and values alternating)
|
# Check if Clojure style (flat list with symbols and values alternating)
|
||||||
if isinstance(bindings[0], Symbol):
|
if _is_symbol(bindings[0]):
|
||||||
# Clojure style: [x 1 y 2]
|
# Clojure style: [x 1 y 2]
|
||||||
pairs = []
|
pairs = []
|
||||||
i = 0
|
i = 0
|
||||||
while i < len(bindings) - 1:
|
while i < len(bindings) - 1:
|
||||||
name = bindings[i].name if isinstance(bindings[i], Symbol) else bindings[i]
|
name = bindings[i].name if _is_symbol(bindings[i]) else bindings[i]
|
||||||
value = bindings[i + 1]
|
value = bindings[i + 1]
|
||||||
pairs.append((name, value))
|
pairs.append((name, value))
|
||||||
i += 2
|
i += 2
|
||||||
@@ -323,7 +374,7 @@ class Interpreter:
|
|||||||
# Scheme style: ((x 1) (y 2))
|
# Scheme style: ((x 1) (y 2))
|
||||||
pairs = []
|
pairs = []
|
||||||
for binding in bindings:
|
for binding in bindings:
|
||||||
name = binding[0].name if isinstance(binding[0], Symbol) else binding[0]
|
name = binding[0].name if _is_symbol(binding[0]) else binding[0]
|
||||||
value = binding[1]
|
value = binding[1]
|
||||||
pairs.append((name, value))
|
pairs.append((name, value))
|
||||||
return pairs
|
return pairs
|
||||||
@@ -360,12 +411,83 @@ class Interpreter:
|
|||||||
"""Evaluate cond expression."""
|
"""Evaluate cond expression."""
|
||||||
for clause in expr[1:]:
|
for clause in expr[1:]:
|
||||||
test = clause[0]
|
test = clause[0]
|
||||||
if isinstance(test, Symbol) and test.name == 'else':
|
if _is_symbol(test) and test.name == 'else':
|
||||||
return self.eval(clause[1], env)
|
return self.eval(clause[1], env)
|
||||||
if self.eval(test, env):
|
if self.eval(test, env):
|
||||||
return self.eval(clause[1], env)
|
return self.eval(clause[1], env)
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
def _eval_with_primitives(self, expr: Any, env: Environment) -> Any:
|
||||||
|
"""
|
||||||
|
Evaluate with-primitives: scoped primitive library loading.
|
||||||
|
|
||||||
|
Syntax:
|
||||||
|
(with-primitives "math"
|
||||||
|
(sin (* x pi)))
|
||||||
|
|
||||||
|
(with-primitives "math" :path "custom/math.py"
|
||||||
|
body)
|
||||||
|
|
||||||
|
The primitives from the library are only available within the body.
|
||||||
|
"""
|
||||||
|
# Parse library name and optional path
|
||||||
|
lib_name = expr[1]
|
||||||
|
if _is_symbol(lib_name):
|
||||||
|
lib_name = lib_name.name
|
||||||
|
|
||||||
|
path = None
|
||||||
|
body_start = 2
|
||||||
|
|
||||||
|
# Check for :path keyword
|
||||||
|
if len(expr) > 2 and _is_keyword(expr[2]) and expr[2].name == 'path':
|
||||||
|
path = expr[3]
|
||||||
|
body_start = 4
|
||||||
|
|
||||||
|
# Load the primitive library
|
||||||
|
primitives = self.load_primitive_library(lib_name, path)
|
||||||
|
|
||||||
|
# Create new environment with primitives
|
||||||
|
new_env = Environment(env)
|
||||||
|
for name, fn in primitives.items():
|
||||||
|
new_env.set(name, fn)
|
||||||
|
|
||||||
|
# Evaluate body in new environment
|
||||||
|
result = None
|
||||||
|
for e in expr[body_start:]:
|
||||||
|
result = self.eval(e, new_env)
|
||||||
|
return result
|
||||||
|
|
||||||
|
def _eval_require_primitives(self, expr: Any, env: Environment) -> Any:
|
||||||
|
"""
|
||||||
|
Evaluate require-primitives: load primitives into current scope.
|
||||||
|
|
||||||
|
Syntax:
|
||||||
|
(require-primitives "math" "color" "filters")
|
||||||
|
|
||||||
|
Unlike with-primitives, this loads into the current environment
|
||||||
|
(typically used at top-level to set up an effect's dependencies).
|
||||||
|
"""
|
||||||
|
for lib_expr in expr[1:]:
|
||||||
|
if _is_symbol(lib_expr):
|
||||||
|
lib_name = lib_expr.name
|
||||||
|
else:
|
||||||
|
lib_name = lib_expr
|
||||||
|
|
||||||
|
primitives = self.load_primitive_library(lib_name)
|
||||||
|
for name, fn in primitives.items():
|
||||||
|
env.set(name, fn)
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def load_primitive_library(self, name: str, path: str = None) -> dict:
|
||||||
|
"""
|
||||||
|
Load a primitive library by name or path.
|
||||||
|
|
||||||
|
Returns dict of {name: function}.
|
||||||
|
"""
|
||||||
|
from .primitive_libs import load_primitive_library
|
||||||
|
return load_primitive_library(name, path)
|
||||||
|
|
||||||
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
|
def _eval_ascii_fx_zone(self, expr: Any, env: Environment) -> Any:
|
||||||
"""
|
"""
|
||||||
Evaluate ascii-fx-zone special form.
|
Evaluate ascii-fx-zone special form.
|
||||||
@@ -387,8 +509,18 @@ class Interpreter:
|
|||||||
The expression parameters (:char_hue, etc.) are NOT pre-evaluated.
|
The expression parameters (:char_hue, etc.) are NOT pre-evaluated.
|
||||||
They are passed as raw S-expressions to the primitive which
|
They are passed as raw S-expressions to the primitive which
|
||||||
evaluates them per-zone with zone context variables injected.
|
evaluates them per-zone with zone context variables injected.
|
||||||
|
|
||||||
|
Requires: (require-primitives "ascii")
|
||||||
"""
|
"""
|
||||||
from .primitives import prim_ascii_fx_zone
|
# Look up ascii-fx-zone primitive from environment
|
||||||
|
# It must be loaded via (require-primitives "ascii")
|
||||||
|
try:
|
||||||
|
prim_ascii_fx_zone = env.get('ascii-fx-zone')
|
||||||
|
except NameError:
|
||||||
|
raise NameError(
|
||||||
|
"ascii-fx-zone primitive not found. "
|
||||||
|
"Add (require-primitives \"ascii\") to your effect file."
|
||||||
|
)
|
||||||
|
|
||||||
# Expression parameter names that should NOT be evaluated
|
# Expression parameter names that should NOT be evaluated
|
||||||
expr_params = {'char_hue', 'char_saturation', 'char_brightness',
|
expr_params = {'char_hue', 'char_saturation', 'char_brightness',
|
||||||
@@ -421,7 +553,7 @@ class Interpreter:
|
|||||||
i = 2
|
i = 2
|
||||||
while i < len(expr):
|
while i < len(expr):
|
||||||
item = expr[i]
|
item = expr[i]
|
||||||
if isinstance(item, Keyword):
|
if _is_keyword(item):
|
||||||
if i + 1 >= len(expr):
|
if i + 1 >= len(expr):
|
||||||
break
|
break
|
||||||
value_expr = expr[i + 1]
|
value_expr = expr[i + 1]
|
||||||
@@ -431,7 +563,7 @@ class Interpreter:
|
|||||||
# Resolve symbol references but don't evaluate expressions
|
# Resolve symbol references but don't evaluate expressions
|
||||||
# This handles the case where effect definition passes a param like :char_hue char_hue
|
# This handles the case where effect definition passes a param like :char_hue char_hue
|
||||||
resolved = value_expr
|
resolved = value_expr
|
||||||
if isinstance(value_expr, Symbol):
|
if _is_symbol(value_expr):
|
||||||
try:
|
try:
|
||||||
resolved = env.get(value_expr.name)
|
resolved = env.get(value_expr.name)
|
||||||
except NameError:
|
except NameError:
|
||||||
@@ -458,7 +590,7 @@ class Interpreter:
|
|||||||
cols = int(value)
|
cols = int(value)
|
||||||
elif kw_name == 'char_size':
|
elif kw_name == 'char_size':
|
||||||
# Handle nil/None values
|
# Handle nil/None values
|
||||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
if value is None or (_is_symbol(value) and value.name == 'nil'):
|
||||||
char_size = None
|
char_size = None
|
||||||
else:
|
else:
|
||||||
char_size = int(value)
|
char_size = int(value)
|
||||||
@@ -471,14 +603,12 @@ class Interpreter:
|
|||||||
elif kw_name == 'contrast':
|
elif kw_name == 'contrast':
|
||||||
contrast = float(value)
|
contrast = float(value)
|
||||||
elif kw_name == 'energy':
|
elif kw_name == 'energy':
|
||||||
if value is None or (isinstance(value, Symbol) and value.name == 'nil'):
|
if value is None or (_is_symbol(value) and value.name == 'nil'):
|
||||||
energy = None
|
energy = None
|
||||||
else:
|
else:
|
||||||
energy = float(value)
|
energy = float(value)
|
||||||
extra_params['energy'] = energy
|
|
||||||
elif kw_name == 'rotation_scale':
|
elif kw_name == 'rotation_scale':
|
||||||
rotation_scale = float(value)
|
rotation_scale = float(value)
|
||||||
extra_params['rotation_scale'] = rotation_scale
|
|
||||||
else:
|
else:
|
||||||
# Store any other params for lambdas to access
|
# Store any other params for lambdas to access
|
||||||
extra_params[kw_name] = value
|
extra_params[kw_name] = value
|
||||||
@@ -523,10 +653,25 @@ class Interpreter:
|
|||||||
|
|
||||||
# Call the primitive with interpreter and env for expression evaluation
|
# Call the primitive with interpreter and env for expression evaluation
|
||||||
return prim_ascii_fx_zone(
|
return prim_ascii_fx_zone(
|
||||||
frame, cols, char_size, alphabet, color_mode, background, contrast,
|
frame,
|
||||||
char_hue, char_saturation, char_brightness,
|
cols=cols,
|
||||||
char_scale, char_rotation, char_jitter,
|
char_size=char_size,
|
||||||
self, env, extra_params, cell_effect
|
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,
|
||||||
|
_interp=self,
|
||||||
|
_env=env,
|
||||||
|
**extra_params
|
||||||
)
|
)
|
||||||
|
|
||||||
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
def _define_effect(self, expr: Any, env: Environment) -> EffectDefinition:
|
||||||
@@ -542,7 +687,7 @@ class Interpreter:
|
|||||||
|
|
||||||
Effects MUST use :params syntax. Legacy ((param default) ...) is not supported.
|
Effects MUST use :params syntax. Legacy ((param default) ...) is not supported.
|
||||||
"""
|
"""
|
||||||
name = expr[1].name if isinstance(expr[1], Symbol) else expr[1]
|
name = expr[1].name if _is_symbol(expr[1]) else expr[1]
|
||||||
|
|
||||||
params = {}
|
params = {}
|
||||||
body = None
|
body = None
|
||||||
@@ -552,7 +697,7 @@ class Interpreter:
|
|||||||
i = 2
|
i = 2
|
||||||
while i < len(expr):
|
while i < len(expr):
|
||||||
item = expr[i]
|
item = expr[i]
|
||||||
if isinstance(item, Keyword) and item.name == "params":
|
if _is_keyword(item) and item.name == "params":
|
||||||
# :params syntax
|
# :params syntax
|
||||||
if i + 1 >= len(expr):
|
if i + 1 >= len(expr):
|
||||||
raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword")
|
raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword")
|
||||||
@@ -560,7 +705,7 @@ class Interpreter:
|
|||||||
params = self._parse_params_block(params_list)
|
params = self._parse_params_block(params_list)
|
||||||
found_params = True
|
found_params = True
|
||||||
i += 2
|
i += 2
|
||||||
elif isinstance(item, Keyword):
|
elif _is_keyword(item):
|
||||||
# Skip other keywords (like :desc)
|
# Skip other keywords (like :desc)
|
||||||
i += 2
|
i += 2
|
||||||
elif body is None:
|
elif body is None:
|
||||||
@@ -605,7 +750,7 @@ class Interpreter:
|
|||||||
|
|
||||||
# First element is the parameter name
|
# First element is the parameter name
|
||||||
first = param_def[0]
|
first = param_def[0]
|
||||||
if isinstance(first, Symbol):
|
if _is_symbol(first):
|
||||||
param_name = first.name
|
param_name = first.name
|
||||||
elif isinstance(first, str):
|
elif isinstance(first, str):
|
||||||
param_name = first
|
param_name = first
|
||||||
@@ -617,7 +762,7 @@ class Interpreter:
|
|||||||
i = 1
|
i = 1
|
||||||
while i < len(param_def):
|
while i < len(param_def):
|
||||||
item = param_def[i]
|
item = param_def[i]
|
||||||
if isinstance(item, Keyword):
|
if _is_keyword(item):
|
||||||
if i + 1 >= len(param_def):
|
if i + 1 >= len(param_def):
|
||||||
break
|
break
|
||||||
kw_value = param_def[i + 1]
|
kw_value = param_def[i + 1]
|
||||||
@@ -772,14 +917,26 @@ class Interpreter:
|
|||||||
# =============================================================================
|
# =============================================================================
|
||||||
|
|
||||||
_interpreter = None
|
_interpreter = None
|
||||||
|
_interpreter_minimal = None
|
||||||
|
|
||||||
|
|
||||||
def get_interpreter() -> Interpreter:
|
def get_interpreter(minimal_primitives: bool = False) -> Interpreter:
|
||||||
"""Get or create the global interpreter."""
|
"""Get or create the global interpreter.
|
||||||
global _interpreter
|
|
||||||
if _interpreter is None:
|
Args:
|
||||||
_interpreter = Interpreter()
|
minimal_primitives: If True, return interpreter with only core primitives.
|
||||||
return _interpreter
|
Additional primitives must be loaded with require-primitives or with-primitives.
|
||||||
|
"""
|
||||||
|
global _interpreter, _interpreter_minimal
|
||||||
|
|
||||||
|
if minimal_primitives:
|
||||||
|
if _interpreter_minimal is None:
|
||||||
|
_interpreter_minimal = Interpreter(minimal_primitives=True)
|
||||||
|
return _interpreter_minimal
|
||||||
|
else:
|
||||||
|
if _interpreter is None:
|
||||||
|
_interpreter = Interpreter(minimal_primitives=False)
|
||||||
|
return _interpreter
|
||||||
|
|
||||||
|
|
||||||
def load_effect(path: str) -> EffectDefinition:
|
def load_effect(path: str) -> EffectDefinition:
|
||||||
|
|||||||
102
sexp_effects/primitive_libs/__init__.py
Normal file
102
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
sexp_effects/primitive_libs/arrays.py
Normal file
196
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,
|
||||||
|
}
|
||||||
339
sexp_effects/primitive_libs/ascii.py
Normal file
339
sexp_effects/primitive_libs/ascii.py
Normal file
@@ -0,0 +1,339 @@
|
|||||||
|
"""
|
||||||
|
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."""
|
||||||
|
h, w = frame.shape[:2]
|
||||||
|
rows = h // cell_size
|
||||||
|
cols = w // cell_size
|
||||||
|
|
||||||
|
colors = np.zeros((rows, cols, 3), dtype=np.uint8)
|
||||||
|
luminances = np.zeros((rows, cols), dtype=np.float32)
|
||||||
|
|
||||||
|
for r in range(rows):
|
||||||
|
for c in range(cols):
|
||||||
|
y1, y2 = r * cell_size, (r + 1) * cell_size
|
||||||
|
x1, x2 = c * cell_size, (c + 1) * cell_size
|
||||||
|
cell = frame[y1:y2, x1:x2]
|
||||||
|
avg_color = np.mean(cell, axis=(0, 1))
|
||||||
|
colors[r, c] = avg_color.astype(np.uint8)
|
||||||
|
luminances[r, c] = (0.299 * avg_color[0] + 0.587 * avg_color[1] + 0.114 * avg_color[2]) / 255
|
||||||
|
|
||||||
|
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'):
|
||||||
|
# Debug: print what effects are available on first cell
|
||||||
|
if zone.get('row', 0) == 0 and zone.get('col', 0) == 0:
|
||||||
|
import sys
|
||||||
|
print(f"DEBUG: Available effects in interp: {list(interp.effects.keys())}", file=sys.stderr)
|
||||||
|
|
||||||
|
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))
|
||||||
|
|
||||||
|
# Debug: verify bindings
|
||||||
|
if zone.get('row', 0) == 0 and zone.get('col', 0) == 0:
|
||||||
|
import sys
|
||||||
|
print(f"DEBUG: cell_env bindings: {list(cell_env.bindings.keys())}", file=sys.stderr)
|
||||||
|
print(f"DEBUG: 'rotate' in cell_env.bindings: {'rotate' in cell_env.bindings}", file=sys.stderr)
|
||||||
|
print(f"DEBUG: cell_env.has('rotate'): {cell_env.has('rotate')}", file=sys.stderr)
|
||||||
|
# Check Symbol class identity
|
||||||
|
print(f"DEBUG: cell_effect.body = {cell_effect.body}", file=sys.stderr)
|
||||||
|
if hasattr(cell_effect, 'body') and isinstance(cell_effect.body, list) and cell_effect.body:
|
||||||
|
head = cell_effect.body[0]
|
||||||
|
print(f"DEBUG: head = {head}, type = {type(head)}, module = {type(head).__module__}", file=sys.stderr)
|
||||||
|
|
||||||
|
# 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)
|
||||||
|
else:
|
||||||
|
# Fallback: it might be a callable
|
||||||
|
result = cell_effect(cell_img, zone)
|
||||||
|
|
||||||
|
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)}")
|
||||||
|
|
||||||
|
|
||||||
|
PRIMITIVES = {
|
||||||
|
'ascii-fx-zone': prim_ascii_fx_zone,
|
||||||
|
}
|
||||||
116
sexp_effects/primitive_libs/blending.py
Normal file
116
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,
|
||||||
|
}
|
||||||
137
sexp_effects/primitive_libs/color.py
Normal file
137
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,
|
||||||
|
}
|
||||||
90
sexp_effects/primitive_libs/color_ops.py
Normal file
90
sexp_effects/primitive_libs/color_ops.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
"""
|
||||||
|
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 prim_adjust(img, brightness=0, contrast=1):
|
||||||
|
"""Adjust brightness and contrast. Brightness: -255 to 255, Contrast: 0 to 3+."""
|
||||||
|
result = (img.astype(np.float32) - 128) * contrast + 128 + brightness
|
||||||
|
return np.clip(result, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
|
||||||
|
def prim_mix_gray(img, amount):
|
||||||
|
"""Mix image with its grayscale version. 0=original, 1=grayscale."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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."""
|
||||||
|
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)."""
|
||||||
|
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,
|
||||||
|
}
|
||||||
164
sexp_effects/primitive_libs/core.py
Normal file
164
sexp_effects/primitive_libs/core.py
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
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
|
||||||
|
|
||||||
|
|
||||||
|
# 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_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
|
||||||
|
|
||||||
|
|
||||||
|
# Core primitives dict
|
||||||
|
PRIMITIVES = {
|
||||||
|
# Arithmetic
|
||||||
|
'+': prim_add,
|
||||||
|
'-': prim_sub,
|
||||||
|
'*': prim_mul,
|
||||||
|
'/': prim_div,
|
||||||
|
'mod': prim_mod,
|
||||||
|
|
||||||
|
# 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,
|
||||||
|
'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,
|
||||||
|
}
|
||||||
136
sexp_effects/primitive_libs/drawing.py
Normal file
136
sexp_effects/primitive_libs/drawing.py
Normal file
@@ -0,0 +1,136 @@
|
|||||||
|
"""
|
||||||
|
Drawing Primitives Library
|
||||||
|
|
||||||
|
Draw shapes, text, and characters on images.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
|
||||||
|
# Default font (will be loaded lazily)
|
||||||
|
_default_font = None
|
||||||
|
|
||||||
|
|
||||||
|
def _get_default_font(size=16):
|
||||||
|
"""Get default font, creating if needed."""
|
||||||
|
global _default_font
|
||||||
|
if _default_font is None or _default_font.size != size:
|
||||||
|
try:
|
||||||
|
_default_font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf", size)
|
||||||
|
except:
|
||||||
|
_default_font = ImageFont.load_default()
|
||||||
|
return _default_font
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_char(img, char, x, y, font_size=16, color=None):
|
||||||
|
"""Draw a single character at (x, y)."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
pil_img = Image.fromarray(img)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
font = _get_default_font(font_size)
|
||||||
|
draw.text((x, y), char, fill=tuple(color), font=font)
|
||||||
|
return np.array(pil_img)
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_text(img, text, x, y, font_size=16, color=None):
|
||||||
|
"""Draw text string at (x, y)."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
pil_img = Image.fromarray(img)
|
||||||
|
draw = ImageDraw.Draw(pil_img)
|
||||||
|
font = _get_default_font(font_size)
|
||||||
|
draw.text((x, y), text, fill=tuple(color), font=font)
|
||||||
|
return np.array(pil_img)
|
||||||
|
|
||||||
|
|
||||||
|
def prim_fill_rect(img, x, y, w, h, color=None):
|
||||||
|
"""Fill a rectangle with color."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
x, y, w, h = int(x), int(y), int(w), int(h)
|
||||||
|
result[y:y+h, x:x+w] = color
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_rect(img, x, y, w, h, color=None, thickness=1):
|
||||||
|
"""Draw rectangle outline."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
cv2.rectangle(result, (int(x), int(y)), (int(x+w), int(y+h)),
|
||||||
|
tuple(color), thickness)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_line(img, x1, y1, x2, y2, color=None, thickness=1):
|
||||||
|
"""Draw a line from (x1, y1) to (x2, y2)."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
cv2.line(result, (int(x1), int(y1)), (int(x2), int(y2)),
|
||||||
|
tuple(color), thickness)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_circle(img, cx, cy, radius, color=None, thickness=1, fill=False):
|
||||||
|
"""Draw a circle."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
t = -1 if fill else thickness
|
||||||
|
cv2.circle(result, (int(cx), int(cy)), int(radius), tuple(color), t)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_ellipse(img, cx, cy, rx, ry, angle=0, color=None, thickness=1, fill=False):
|
||||||
|
"""Draw an ellipse."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
t = -1 if fill else thickness
|
||||||
|
cv2.ellipse(result, (int(cx), int(cy)), (int(rx), int(ry)),
|
||||||
|
angle, 0, 360, tuple(color), t)
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
def prim_draw_polygon(img, points, color=None, thickness=1, fill=False):
|
||||||
|
"""Draw a polygon from list of [x, y] points."""
|
||||||
|
if color is None:
|
||||||
|
color = [255, 255, 255]
|
||||||
|
|
||||||
|
result = img.copy()
|
||||||
|
pts = np.array(points, dtype=np.int32).reshape((-1, 1, 2))
|
||||||
|
|
||||||
|
if fill:
|
||||||
|
cv2.fillPoly(result, [pts], tuple(color))
|
||||||
|
else:
|
||||||
|
cv2.polylines(result, [pts], True, tuple(color), thickness)
|
||||||
|
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
|
PRIMITIVES = {
|
||||||
|
# Text
|
||||||
|
'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
sexp_effects/primitive_libs/filters.py
Normal file
119
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,
|
||||||
|
}
|
||||||
122
sexp_effects/primitive_libs/geometry.py
Normal file
122
sexp_effects/primitive_libs/geometry.py
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
"""
|
||||||
|
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))
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
144
sexp_effects/primitive_libs/image.py
Normal file
144
sexp_effects/primitive_libs/image.py
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
"""
|
||||||
|
Image Primitives Library
|
||||||
|
|
||||||
|
Basic image operations: dimensions, pixels, resize, crop, paste.
|
||||||
|
"""
|
||||||
|
import numpy as np
|
||||||
|
import cv2
|
||||||
|
|
||||||
|
|
||||||
|
def prim_width(img):
|
||||||
|
return img.shape[1]
|
||||||
|
|
||||||
|
|
||||||
|
def prim_height(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
sexp_effects/primitive_libs/math.py
Normal file
164
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,
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user