From d574d5badd53f3ece34c123d3a5b156a27074c66 Mon Sep 17 00:00:00 2001 From: gilesb Date: Tue, 20 Jan 2026 09:02:34 +0000 Subject: [PATCH] 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 --- effects/ascii_alternating_rotate.sexp | 1 + execute.py | 17 +- run_staged.py | 1 + sexp_effects/effects/ascii_fx_zone.sexp | 5 +- sexp_effects/effects/blur.sexp | 2 + sexp_effects/effects/brightness.sexp | 2 + sexp_effects/effects/contrast.sexp | 2 + sexp_effects/effects/hue_shift.sexp | 2 + sexp_effects/effects/invert.sexp | 2 + sexp_effects/effects/rotate.sexp | 2 + sexp_effects/effects/saturation.sexp | 2 + sexp_effects/interpreter.py | 235 +++++++++++++--- sexp_effects/primitive_libs/__init__.py | 102 +++++++ sexp_effects/primitive_libs/arrays.py | 196 +++++++++++++ sexp_effects/primitive_libs/ascii.py | 339 +++++++++++++++++++++++ sexp_effects/primitive_libs/blending.py | 116 ++++++++ sexp_effects/primitive_libs/color.py | 137 +++++++++ sexp_effects/primitive_libs/color_ops.py | 90 ++++++ sexp_effects/primitive_libs/core.py | 164 +++++++++++ sexp_effects/primitive_libs/drawing.py | 136 +++++++++ sexp_effects/primitive_libs/filters.py | 119 ++++++++ sexp_effects/primitive_libs/geometry.py | 122 ++++++++ sexp_effects/primitive_libs/image.py | 144 ++++++++++ sexp_effects/primitive_libs/math.py | 164 +++++++++++ 24 files changed, 2056 insertions(+), 46 deletions(-) create mode 100644 sexp_effects/primitive_libs/__init__.py create mode 100644 sexp_effects/primitive_libs/arrays.py create mode 100644 sexp_effects/primitive_libs/ascii.py create mode 100644 sexp_effects/primitive_libs/blending.py create mode 100644 sexp_effects/primitive_libs/color.py create mode 100644 sexp_effects/primitive_libs/color_ops.py create mode 100644 sexp_effects/primitive_libs/core.py create mode 100644 sexp_effects/primitive_libs/drawing.py create mode 100644 sexp_effects/primitive_libs/filters.py create mode 100644 sexp_effects/primitive_libs/geometry.py create mode 100644 sexp_effects/primitive_libs/image.py create mode 100644 sexp_effects/primitive_libs/math.py diff --git a/effects/ascii_alternating_rotate.sexp b/effects/ascii_alternating_rotate.sexp index 619ae00..e437b17 100644 --- a/effects/ascii_alternating_rotate.sexp +++ b/effects/ascii_alternating_rotate.sexp @@ -6,6 +6,7 @@ (recipe "ascii_alternating_rotate" :version "1.0" :description "ASCII art with alternating rotation directions per cell" + :minimal-primitives true :encoding (:codec "libx264" :crf 20 :preset "medium" :audio-codec "aac" :fps 30) :params ( diff --git a/execute.py b/execute.py index cc03a7d..211a2f3 100644 --- a/execute.py +++ b/execute.py @@ -348,9 +348,9 @@ def get_encoding(recipe_encoding: dict, step_config: dict) -> dict: class SexpEffectModule: """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 - self.interp = get_interpreter() + self.interp = get_interpreter(minimal_primitives=minimal_primitives) # Load only explicitly declared effects from the recipe's registry # 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 {}) -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).""" 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) 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: 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 results = {} # step_id -> output_path 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: 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() if k not in ("effect", "effect_path", "cid", "encoding", "multi_input")} 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: 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() if k not in ("effect", "effect_path", "cid", "encoding", "type")} print(f" COMPOUND [{i+1}/{len(effects)}]: {effect_name} (Python)", file=sys.stderr) diff --git a/run_staged.py b/run_staged.py index b96823a..4c7e9d9 100644 --- a/run_staged.py +++ b/run_staged.py @@ -118,6 +118,7 @@ def run_staged_recipe( "output_step_id": plan.output_step_id, "analysis": analysis_data, "effects_registry": plan.effects_registry, + "minimal_primitives": plan.minimal_primitives, "steps": [], } diff --git a/sexp_effects/effects/ascii_fx_zone.sexp b/sexp_effects/effects/ascii_fx_zone.sexp index 915470a..69e5340 100644 --- a/sexp_effects/effects/ascii_fx_zone.sexp +++ b/sexp_effects/effects/ascii_fx_zone.sexp @@ -1,5 +1,8 @@ ;; Composable ASCII Art with Per-Zone Expression-Driven Effects -;; +;; Requires ascii primitive library for the ascii-fx-zone primitive + +(require-primitives "ascii") + ;; Two modes of operation: ;; ;; 1. EXPRESSION MODE: Use zone-* variables in expression parameters diff --git a/sexp_effects/effects/blur.sexp b/sexp_effects/effects/blur.sexp index 7171fa5..37af07d 100644 --- a/sexp_effects/effects/blur.sexp +++ b/sexp_effects/effects/blur.sexp @@ -1,5 +1,7 @@ ;; Blur effect - gaussian blur +(require-primitives "filters" "math") + (define-effect blur :params ( (radius :type int :default 5 :range [1 50]) diff --git a/sexp_effects/effects/brightness.sexp b/sexp_effects/effects/brightness.sexp index d094c11..239a79d 100644 --- a/sexp_effects/effects/brightness.sexp +++ b/sexp_effects/effects/brightness.sexp @@ -1,6 +1,8 @@ ;; Brightness effect - adjusts overall brightness ;; Uses vectorized adjust primitive for fast processing +(require-primitives "color_ops") + (define-effect brightness :params ( (amount :type int :default 0 :range [-255 255]) diff --git a/sexp_effects/effects/contrast.sexp b/sexp_effects/effects/contrast.sexp index ce2e960..47f7903 100644 --- a/sexp_effects/effects/contrast.sexp +++ b/sexp_effects/effects/contrast.sexp @@ -1,6 +1,8 @@ ;; Contrast effect - adjusts image contrast ;; Uses vectorized adjust primitive for fast processing +(require-primitives "color_ops") + (define-effect contrast :params ( (amount :type int :default 1 :range [0.5 3]) diff --git a/sexp_effects/effects/hue_shift.sexp b/sexp_effects/effects/hue_shift.sexp index f9f014b..fb6aa79 100644 --- a/sexp_effects/effects/hue_shift.sexp +++ b/sexp_effects/effects/hue_shift.sexp @@ -1,6 +1,8 @@ ;; Hue shift effect - rotates hue values ;; Uses vectorized shift-hsv primitive for fast processing +(require-primitives "color_ops") + (define-effect hue_shift :params ( (degrees :type int :default 0 :range [0 360]) diff --git a/sexp_effects/effects/invert.sexp b/sexp_effects/effects/invert.sexp index fdda9e5..b152a5c 100644 --- a/sexp_effects/effects/invert.sexp +++ b/sexp_effects/effects/invert.sexp @@ -1,6 +1,8 @@ ;; Invert effect - inverts all colors ;; Uses vectorized invert-img primitive for fast processing +(require-primitives "color_ops") + (define-effect invert :params () (invert-img frame)) diff --git a/sexp_effects/effects/rotate.sexp b/sexp_effects/effects/rotate.sexp index 8923296..d886c5a 100644 --- a/sexp_effects/effects/rotate.sexp +++ b/sexp_effects/effects/rotate.sexp @@ -1,5 +1,7 @@ ;; Rotate effect - rotates image +(require-primitives "geometry") + (define-effect rotate :params ( (angle :type int :default 0 :range [-360 360]) diff --git a/sexp_effects/effects/saturation.sexp b/sexp_effects/effects/saturation.sexp index 5abf27b..452d581 100644 --- a/sexp_effects/effects/saturation.sexp +++ b/sexp_effects/effects/saturation.sexp @@ -1,6 +1,8 @@ ;; Saturation effect - adjusts color saturation ;; Uses vectorized shift-hsv primitive for fast processing +(require-primitives "color_ops") + (define-effect saturation :params ( (amount :type int :default 1 :range [0 3]) diff --git a/sexp_effects/interpreter.py b/sexp_effects/interpreter.py index eaa9f41..c647474 100644 --- a/sexp_effects/interpreter.py +++ b/sexp_effects/interpreter.py @@ -13,6 +13,21 @@ from .parser import Symbol, Keyword, parse, parse_file 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: """Lexical environment for variable bindings.""" @@ -68,15 +83,28 @@ class Interpreter: Provides a safe execution environment where only 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 self.global_env = Environment() + self.minimal_primitives = minimal_primitives - # Load primitives - for name, fn in PRIMITIVES.items(): - self.global_env.set(name, fn) + if minimal_primitives: + # Load only core primitives + 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 self.global_env.set('true', True) @@ -98,10 +126,12 @@ class Interpreter: if expr is 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) - if isinstance(expr, Keyword): + # Handle Keyword (duck typing) + if _is_keyword(expr): return expr # Keywords evaluate to themselves if isinstance(expr, np.ndarray): @@ -115,7 +145,7 @@ class Interpreter: head = expr[0] # Special forms - if isinstance(head, Symbol): + if _is_symbol(head): form = head.name # Quote @@ -125,7 +155,7 @@ class Interpreter: # Define if form == 'define': name = expr[1] - if isinstance(name, Symbol): + if _is_symbol(name): value = self.eval(expr[2], env) self.global_env.set(name.name, value) return value @@ -138,7 +168,7 @@ class Interpreter: # Lambda 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] return Lambda(params, body, env) @@ -205,7 +235,7 @@ class Interpreter: # Set! (mutation) 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) # Find and update in appropriate scope scope = env @@ -220,7 +250,7 @@ class Interpreter: if form == 'state-get': state = env.get('__state__') key = self.eval(expr[1], env) - if isinstance(key, Symbol): + if _is_symbol(key): key = key.name default = self.eval(expr[2], env) if len(expr) > 2 else None return state.get(key, default) @@ -228,7 +258,7 @@ class Interpreter: if form == 'state-set': state = env.get('__state__') key = self.eval(expr[1], env) - if isinstance(key, Symbol): + if _is_symbol(key): key = key.name value = self.eval(expr[2], env) state[key] = value @@ -238,6 +268,14 @@ class Interpreter: if form == 'ascii-fx-zone': 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 fn = self.eval(head, env) args = [self.eval(arg, env) for arg in expr[1:]] @@ -247,7 +285,7 @@ class Interpreter: kw_args = {} i = 0 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 i += 2 else: @@ -291,6 +329,19 @@ class Interpreter: else: 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 if kwargs: return fn(*wrapped_args, **kwargs) @@ -309,12 +360,12 @@ class Interpreter: return [] # 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] pairs = [] i = 0 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] pairs.append((name, value)) i += 2 @@ -323,7 +374,7 @@ class Interpreter: # Scheme style: ((x 1) (y 2)) pairs = [] 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] pairs.append((name, value)) return pairs @@ -360,12 +411,83 @@ class Interpreter: """Evaluate cond expression.""" for clause in expr[1:]: 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) if self.eval(test, env): return self.eval(clause[1], env) 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: """ Evaluate ascii-fx-zone special form. @@ -387,8 +509,18 @@ class Interpreter: The expression parameters (:char_hue, etc.) are NOT pre-evaluated. They are passed as raw S-expressions to the primitive which 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 expr_params = {'char_hue', 'char_saturation', 'char_brightness', @@ -421,7 +553,7 @@ class Interpreter: i = 2 while i < len(expr): item = expr[i] - if isinstance(item, Keyword): + if _is_keyword(item): if i + 1 >= len(expr): break value_expr = expr[i + 1] @@ -431,7 +563,7 @@ class Interpreter: # Resolve symbol references but don't evaluate expressions # This handles the case where effect definition passes a param like :char_hue char_hue resolved = value_expr - if isinstance(value_expr, Symbol): + if _is_symbol(value_expr): try: resolved = env.get(value_expr.name) except NameError: @@ -458,7 +590,7 @@ class Interpreter: cols = int(value) elif kw_name == 'char_size': # 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 else: char_size = int(value) @@ -471,14 +603,12 @@ class Interpreter: elif kw_name == 'contrast': contrast = float(value) 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 else: energy = float(value) - extra_params['energy'] = energy elif kw_name == 'rotation_scale': rotation_scale = float(value) - extra_params['rotation_scale'] = rotation_scale else: # Store any other params for lambdas to access extra_params[kw_name] = value @@ -523,10 +653,25 @@ class Interpreter: # Call the primitive with interpreter and env for expression evaluation return prim_ascii_fx_zone( - frame, cols, char_size, alphabet, color_mode, background, contrast, - char_hue, char_saturation, char_brightness, - char_scale, char_rotation, char_jitter, - self, env, extra_params, cell_effect + frame, + cols=cols, + char_size=char_size, + alphabet=alphabet, + color_mode=color_mode, + background=background, + contrast=contrast, + char_hue=char_hue, + char_saturation=char_saturation, + char_brightness=char_brightness, + char_scale=char_scale, + char_rotation=char_rotation, + char_jitter=char_jitter, + cell_effect=cell_effect, + energy=energy, + rotation_scale=rotation_scale, + _interp=self, + _env=env, + **extra_params ) 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. """ - 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 = {} body = None @@ -552,7 +697,7 @@ class Interpreter: i = 2 while i < len(expr): item = expr[i] - if isinstance(item, Keyword) and item.name == "params": + if _is_keyword(item) and item.name == "params": # :params syntax if i + 1 >= len(expr): raise SyntaxError(f"Effect '{name}': Missing params list after :params keyword") @@ -560,7 +705,7 @@ class Interpreter: params = self._parse_params_block(params_list) found_params = True i += 2 - elif isinstance(item, Keyword): + elif _is_keyword(item): # Skip other keywords (like :desc) i += 2 elif body is None: @@ -605,7 +750,7 @@ class Interpreter: # First element is the parameter name first = param_def[0] - if isinstance(first, Symbol): + if _is_symbol(first): param_name = first.name elif isinstance(first, str): param_name = first @@ -617,7 +762,7 @@ class Interpreter: i = 1 while i < len(param_def): item = param_def[i] - if isinstance(item, Keyword): + if _is_keyword(item): if i + 1 >= len(param_def): break kw_value = param_def[i + 1] @@ -772,14 +917,26 @@ class Interpreter: # ============================================================================= _interpreter = None +_interpreter_minimal = None -def get_interpreter() -> Interpreter: - """Get or create the global interpreter.""" - global _interpreter - if _interpreter is None: - _interpreter = Interpreter() - return _interpreter +def get_interpreter(minimal_primitives: bool = False) -> Interpreter: + """Get or create the global interpreter. + + Args: + minimal_primitives: If True, return interpreter with only core primitives. + 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: diff --git a/sexp_effects/primitive_libs/__init__.py b/sexp_effects/primitive_libs/__init__.py new file mode 100644 index 0000000..47ee174 --- /dev/null +++ b/sexp_effects/primitive_libs/__init__.py @@ -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() diff --git a/sexp_effects/primitive_libs/arrays.py b/sexp_effects/primitive_libs/arrays.py new file mode 100644 index 0000000..61da196 --- /dev/null +++ b/sexp_effects/primitive_libs/arrays.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/ascii.py b/sexp_effects/primitive_libs/ascii.py new file mode 100644 index 0000000..74a009f --- /dev/null +++ b/sexp_effects/primitive_libs/ascii.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/blending.py b/sexp_effects/primitive_libs/blending.py new file mode 100644 index 0000000..0bf345d --- /dev/null +++ b/sexp_effects/primitive_libs/blending.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/color.py b/sexp_effects/primitive_libs/color.py new file mode 100644 index 0000000..0b6854b --- /dev/null +++ b/sexp_effects/primitive_libs/color.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/color_ops.py b/sexp_effects/primitive_libs/color_ops.py new file mode 100644 index 0000000..dd9076c --- /dev/null +++ b/sexp_effects/primitive_libs/color_ops.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/core.py b/sexp_effects/primitive_libs/core.py new file mode 100644 index 0000000..9ac2a64 --- /dev/null +++ b/sexp_effects/primitive_libs/core.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/drawing.py b/sexp_effects/primitive_libs/drawing.py new file mode 100644 index 0000000..ddd1a01 --- /dev/null +++ b/sexp_effects/primitive_libs/drawing.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/filters.py b/sexp_effects/primitive_libs/filters.py new file mode 100644 index 0000000..a66f107 --- /dev/null +++ b/sexp_effects/primitive_libs/filters.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/geometry.py b/sexp_effects/primitive_libs/geometry.py new file mode 100644 index 0000000..60b0636 --- /dev/null +++ b/sexp_effects/primitive_libs/geometry.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/image.py b/sexp_effects/primitive_libs/image.py new file mode 100644 index 0000000..beae3ce --- /dev/null +++ b/sexp_effects/primitive_libs/image.py @@ -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, +} diff --git a/sexp_effects/primitive_libs/math.py b/sexp_effects/primitive_libs/math.py new file mode 100644 index 0000000..140ad3e --- /dev/null +++ b/sexp_effects/primitive_libs/math.py @@ -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, +}