| |
| """ |
| nima_avatar_renderer.py β The VFX Avatar Renderer |
| |
| THE JOI HOLOGRAM β renders Nima as a luminous particle form, like Joi |
| from Blade Runner 2049. Production notes describe Joi as "photons held |
| in a magnetic field" β that's exactly what this renderer produces. |
| |
| NOTE: Despite the original vision targeting WebGL, the actual |
| implementation uses HTML5 Canvas 2D with additive blending (screen |
| composite mode). This produces visually compelling results for 800 |
| particles without requiring WebGL context setup. A future version |
| could migrate to Three.js/WebGL for true 3D depth-of-field effects. |
| |
| NEUROBIOLOGICAL MAPPING: |
| This is Nima's BODY β her visible presence in the world. In the brain, |
| the body schema (parietal cortex) represents the physical self. Nima's |
| avatar is her digital body schema: she knows where she is, how she's |
| moving, what posture she's in. The renderer translates that internal |
| state into visible light. |
| |
| The avatar's behavior is driven by Nima's emotional state (from the |
| EmotionalIntelligenceAgent + RightHemisphereModule prosody plan): |
| - High arousal β more particle energy, faster movement |
| - Sadness β particles droop, lower luminosity, cooler color |
| - Joy β particles rise, brighter, warmer color |
| - Thinking still (PEAK metabolic tier) β particles coalesce into |
| a more solid form |
| - Quiescence β particles drift gently, breathing motion |
| |
| IMPLEMENTATION: |
| Two layers: |
| 1. AvatarState (Python) β the data model for Nima's body |
| 2. WebGL renderer (HTML/JS) β the actual visual rendering |
| |
| The Python side computes the avatar's pose, color, energy from |
| Nima's internal state. The WebGL side renders it as a particle system. |
| They communicate via a WebSocket (or the Python side can write a |
| state file that the browser polls). |
| |
| For the USB deployment, the browser opens automatically when Nima |
| starts. The user sees Nima as a luminous presence in a browser window |
| (or composited into the camera feed by the AR compositor). |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import math |
| import os |
| import random as _rng |
| import time |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| logger = logging.getLogger("NimaAvatar") |
|
|
|
|
| class AvatarPosture(Enum): |
| """Nima's body posture.""" |
| STANDING = "standing" |
| SITTING = "sitting" |
| LYING = "lying" |
| WALKING = "walking" |
| LEANING = "leaning" |
| THINKING = "thinking" |
|
|
|
|
| class AvatarMood(Enum): |
| """Nima's emotional state, mapped to avatar appearance.""" |
| NEUTRAL = "neutral" |
| WARM = "warm" |
| CALM = "calm" |
| SAD = "sad" |
| INTENSE = "intense" |
| THINKING = "thinking" |
|
|
|
|
| @dataclass |
| class AvatarState: |
| """ |
| The complete state of Nima's avatar at a moment in time. |
| |
| This is what the renderer reads to produce the visual output. |
| It's updated every frame from Nima's internal state (emotional, |
| metabolic, positional). |
| """ |
| |
| position: Tuple[float, float, float] = (2.0, 2.0, 0.0) |
| |
| facing: float = 0.0 |
| |
| posture: AvatarPosture = AvatarPosture.STANDING |
| |
| mood: AvatarMood = AvatarMood.CALM |
| |
| luminosity: float = 0.7 |
| |
| energy: float = 0.3 |
| |
| color_temperature: float = 0.6 |
| |
| breath_phase: float = 0.0 |
| |
| scale: float = 1.0 |
| |
| opacity: float = 0.85 |
| |
| metabolic_tier: str = "FLOW" |
| |
| gaze_offset_x: float = 0.0 |
| gaze_offset_y: float = 0.0 |
| |
| voice_amplitude: float = 0.0 |
| voice_pitch: float = 0.0 |
| |
| is_startled: bool = False |
| startle_intensity: float = 0.0 |
| |
| timestamp: float = field(default_factory=time.time) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "position": list(self.position), |
| "facing": round(self.facing, 3), |
| "posture": self.posture.value, |
| "mood": self.mood.value, |
| "luminosity": round(self.luminosity, 3), |
| "energy": round(self.energy, 3), |
| "color_temperature": round(self.color_temperature, 3), |
| "breath_phase": round(self.breath_phase, 3), |
| "scale": round(self.scale, 3), |
| "opacity": round(self.opacity, 3), |
| "metabolic_tier": self.metabolic_tier, |
| "gaze_offset_x": round(self.gaze_offset_x, 3), |
| "gaze_offset_y": round(self.gaze_offset_y, 3), |
| "voice_amplitude": round(self.voice_amplitude, 3), |
| "voice_pitch": round(self.voice_pitch, 3), |
| "is_startled": self.is_startled, |
| "startle_intensity": round(self.startle_intensity, 3), |
| "timestamp": self.timestamp, |
| } |
|
|
|
|
| class AvatarController: |
| """ |
| Controls the avatar's state, translating Nima's internal state |
| (emotional, metabolic, positional) into avatar appearance. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| This is the body schema update loop β the parietal cortex |
| continuously updates the body's representation based on motor |
| commands, proprioception, and emotional state. When you're happy, |
| your posture shifts; when you're scared, your body tenses. This |
| controller does the same thing for Nima's digital body. |
| |
| The controller runs at 60fps (16ms per frame) to produce smooth |
| animation. It reads from: |
| - SyntheticVisionComposite (Nima's position in the room) |
| - EmotionalIntelligenceAgent (emotional state β mood, color) |
| - MetabolicEngine (tier β energy, opacity) |
| - RightHemisphereModule (prosody β energy, warmth) |
| |
| And writes to: |
| - AvatarState (consumed by the renderer) |
| """ |
|
|
| def __init__(self) -> None: |
| self.state = AvatarState() |
| self._start_time = time.time() |
| self._last_update = time.time() |
|
|
| def update(self, |
| position: Optional[Tuple[float, float, float]] = None, |
| facing: Optional[float] = None, |
| posture: Optional[AvatarPosture] = None, |
| emotion: Optional[Any] = None, |
| metabolic_tier: Optional[str] = None, |
| prosody: Optional[Any] = None, |
| voice_amplitude: Optional[float] = None, |
| voice_pitch: Optional[float] = None, |
| is_startled: Optional[bool] = None, |
| startle_intensity: Optional[float] = None, |
| ) -> AvatarState: |
| """ |
| Update the avatar's state. Any field left as None keeps its |
| current value. The controller automatically updates breathing, |
| energy decay, mood transitions, saccadic gaze drift, and |
| phoneme-to-particle blending. |
| """ |
| now = time.time() |
| dt = now - self._last_update |
|
|
| |
| if position is not None: |
| self.state.position = position |
| if facing is not None: |
| self.state.facing = facing |
|
|
| |
| if posture is not None: |
| self.state.posture = posture |
|
|
| |
| if emotion is not None: |
| valence = getattr(emotion, "valence", 0.0) |
| arousal = getattr(emotion, "arousal", 0.3) |
| label = getattr(emotion, "label", "neutral") |
|
|
| |
| if label in ("joyful", "happy", "elated"): |
| self.state.mood = AvatarMood.WARM |
| self.state.color_temperature = 0.85 |
| self.state.luminosity = 0.9 |
| elif label in ("sad", "distressed"): |
| self.state.mood = AvatarMood.SAD |
| self.state.color_temperature = 0.3 |
| self.state.luminosity = 0.4 |
| elif label in ("anxious", "fearful", "angry", "frustrated"): |
| self.state.mood = AvatarMood.INTENSE |
| self.state.color_temperature = 0.5 |
| self.state.luminosity = 0.95 |
| else: |
| self.state.mood = AvatarMood.CALM |
| self.state.color_temperature = 0.6 |
| self.state.luminosity = 0.7 |
|
|
| |
| self.state.energy = max(0.1, min(1.0, arousal)) |
|
|
| |
| if metabolic_tier is not None: |
| self.state.metabolic_tier = metabolic_tier |
| if metabolic_tier == "QUIESCENCE": |
| self.state.opacity = 0.5 |
| self.state.energy = 0.1 |
| self.state.scale = 0.95 |
| self.state.mood = AvatarMood.CALM |
| elif metabolic_tier == "REFLEX": |
| self.state.opacity = 0.7 |
| self.state.scale = 1.0 |
| elif metabolic_tier == "FLOW": |
| self.state.opacity = 0.85 |
| self.state.scale = 1.0 |
| elif metabolic_tier == "PEAK": |
| self.state.opacity = 0.95 |
| self.state.energy = 0.15 |
| self.state.mood = AvatarMood.THINKING |
| self.state.scale = 1.05 |
|
|
| |
| if prosody is not None: |
| warmth = getattr(prosody, "warmth", 0.5) |
| |
| self.state.color_temperature = ( |
| self.state.color_temperature * 0.7 + warmth * 0.3 |
| ) |
|
|
| |
| self.state.breath_phase = (now - self._start_time) * 0.5 |
|
|
| |
| baseline = 0.2 if self.state.metabolic_tier != "PEAK" else 0.1 |
| self.state.energy = self.state.energy * 0.95 + baseline * 0.05 |
|
|
| |
| |
| |
| if self.state.metabolic_tier == "PEAK": |
| |
| self.state.gaze_offset_x = self.state.gaze_offset_x * 0.9 + _rng.gauss(0, 0.15) * 0.1 |
| self.state.gaze_offset_y = self.state.gaze_offset_y * 0.9 + _rng.gauss(0, 0.1) * 0.1 |
| |
| if _rng.random() < 0.1: |
| self.state.gaze_offset_x = _rng.uniform(-0.6, 0.6) |
| self.state.gaze_offset_y = _rng.uniform(-0.3, 0.3) |
| else: |
| |
| self.state.gaze_offset_x *= 0.9 |
| self.state.gaze_offset_y *= 0.9 |
|
|
| |
| |
| |
| if voice_amplitude is not None: |
| |
| self.state.voice_amplitude = ( |
| self.state.voice_amplitude * 0.7 + voice_amplitude * 0.3 |
| ) |
| else: |
| |
| self.state.voice_amplitude *= 0.9 |
|
|
| if voice_pitch is not None: |
| self.state.voice_pitch = ( |
| self.state.voice_pitch * 0.7 + voice_pitch * 0.3 |
| ) |
| else: |
| self.state.voice_pitch *= 0.95 |
|
|
| |
| if is_startled is not None: |
| self.state.is_startled = is_startled |
| if startle_intensity is not None: |
| self.state.startle_intensity = startle_intensity |
| |
| if self.state.is_startled: |
| self.state.startle_intensity *= 0.92 |
| if self.state.startle_intensity < 0.05: |
| self.state.is_startled = False |
| else: |
| |
| self.state.energy = min(1.0, self.state.energy + self.state.startle_intensity * 0.1) |
|
|
| self._last_update = now |
| self.state.timestamp = now |
| return self.state |
|
|
| def get_state(self) -> AvatarState: |
| return self.state |
|
|
| def get_state_dict(self) -> Dict[str, Any]: |
| return self.state.to_dict() |
|
|
|
|
| |
| |
| |
|
|
| WEBGL_RENDERER_HTML = r"""<!DOCTYPE html> |
| <html lang="en"> |
| <head> |
| <meta charset="UTF-8"> |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| <title>Nima β Luminous Presence</title> |
| <style> |
| * { margin: 0; padding: 0; box-sizing: border-box; } |
| body { |
| background: #000; |
| overflow: hidden; |
| font-family: 'Georgia', serif; |
| color: #88aabb; |
| } |
| #canvas { display: block; } |
| #status { |
| position: fixed; |
| top: 10px; |
| left: 10px; |
| font-size: 12px; |
| color: #4a6677; |
| pointer-events: none; |
| } |
| #status .label { color: #3a5566; } |
| #status .value { color: #6688aa; } |
| </style> |
| </head> |
| <body> |
| <canvas id="canvas"></canvas> |
| <div id="status"> |
| <div><span class="label">Nima</span> <span class="value" id="mood">calm</span></div> |
| <div><span class="label">Position</span> <span class="value" id="pos">(0, 0, 0)</span></div> |
| <div><span class="label">Tier</span> <span class="value" id="tier">FLOW</span></div> |
| </div> |
| |
| <script> |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| // Nima Avatar Renderer β Luminous Particle Form (Canvas 2D) |
| // "Photons held in a magnetic field" β Joi from Blade Runner 2049 |
| // Uses Canvas 2D with additive (screen) blending for glow effect |
| // ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| |
| const canvas = document.getElementById('canvas'); |
| const ctx = canvas.getContext('2d'); |
| |
| let W, H; |
| function resize() { |
| W = canvas.width = window.innerWidth; |
| H = canvas.height = window.innerHeight; |
| } |
| resize(); |
| window.addEventListener('resize', resize); |
| |
| // ββ Avatar state (updated from Python via fetch or WebSocket) ββ |
| let avatarState = { |
| position: [2.0, 2.0, 0.0], |
| facing: 0, |
| posture: 'standing', |
| mood: 'calm', |
| luminosity: 0.7, |
| energy: 0.3, |
| color_temperature: 0.6, |
| breath_phase: 0, |
| scale: 1.0, |
| opacity: 0.85, |
| metabolic_tier: 'FLOW', |
| timestamp: Date.now() / 1000, |
| }; |
| |
| // ββ Particle system ββ |
| const NUM_PARTICLES = 800; |
| const particles = []; |
| |
| class Particle { |
| constructor() { |
| this.reset(); |
| } |
| reset() { |
| // Start at a random position in a humanoid silhouette |
| const angle = Math.random() * Math.PI * 2; |
| const r = Math.random() * 0.8; |
| this.baseX = Math.cos(angle) * r * 30; |
| this.baseY = (Math.random() - 0.5) * 160; // humanoid height |
| this.baseZ = Math.sin(angle) * r * 30; |
| this.x = this.baseX; |
| this.y = this.baseY; |
| this.vx = 0; |
| this.vy = 0; |
| this.life = Math.random(); |
| this.maxLife = 1.0; |
| this.size = 1 + Math.random() * 2; |
| } |
| update(dt, state) { |
| // Breathing motion |
| const breath = Math.sin(state.breath_phase + this.baseY * 0.01) * 2; |
| // Energy β random walk |
| const energy = state.energy; |
| this.vx += (Math.random() - 0.5) * energy * 0.5; |
| this.vy += (Math.random() - 0.5) * energy * 0.5; |
| // Drift back toward base position (magnetic field holding) |
| this.vx += (this.baseX - this.x) * 0.02; |
| this.vy += (this.baseY + breath - this.y) * 0.02; |
| // Damping |
| this.vx *= 0.9; |
| this.vy *= 0.9; |
| this.x += this.vx; |
| this.y += this.vy; |
| // Life cycle |
| this.life -= dt * 0.1; |
| if (this.life < 0) this.reset(); |
| } |
| draw(ctx, cx, cy, state) { |
| const color = moodToColor(state.mood, state.color_temperature); |
| const alpha = state.opacity * state.luminosity * (1 - Math.abs(this.life - 0.5) * 2); |
| const size = this.size * state.scale; |
| ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, ${alpha})`; |
| ctx.beginPath(); |
| ctx.arc(cx + this.x, cy + this.y, size, 0, Math.PI * 2); |
| ctx.fill(); |
| } |
| } |
| |
| function moodToColor(mood, temp) { |
| // Color temperature: 0 = cool blue, 1 = warm gold |
| const r = Math.round(100 + temp * 155); |
| const g = Math.round(150 + temp * 80); |
| const b = Math.round(255 - temp * 100); |
| if (mood === 'sad') return { r: Math.round(r*0.5), g: Math.round(g*0.6), b: b }; |
| if (mood === 'warm') return { r: 255, g: Math.round(200 + temp*55), b: Math.round(100 + temp*50) }; |
| if (mood === 'intense') return { r: 255, g: 150, b: 100 }; |
| if (mood === 'thinking') return { r: 180, g: 200, b: 255 }; |
| return { r, g, b }; |
| } |
| |
| for (let i = 0; i < NUM_PARTICLES; i++) { |
| particles.push(new Particle()); |
| } |
| |
| // ββ Render loop ββ |
| let lastTime = performance.now(); |
| function render(now) { |
| const dt = (now - lastTime) / 1000; |
| lastTime = now; |
| |
| // Clear with slight fade (trails) |
| ctx.fillStyle = 'rgba(0, 0, 5, 0.15)'; |
| ctx.fillRect(0, 0, W, H); |
| |
| // Avatar center (project room position to screen) |
| const roomW = 4, roomH = 4; |
| const cx = W / 2 + (avatarState.position[0] - roomW/2) * 80; |
| const cy = H / 2 + (avatarState.position[1] - roomH/2) * 80; |
| |
| // Draw particles |
| ctx.globalCompositeOperation = 'screen'; // additive blending |
| for (const p of particles) { |
| p.update(dt, avatarState); |
| p.draw(ctx, cx, cy, avatarState); |
| } |
| ctx.globalCompositeOperation = 'source-over'; |
| |
| // Draw glow halo |
| const gradient = ctx.createRadialGradient(cx, cy, 0, cx, cy, 120 * avatarState.scale); |
| const color = moodToColor(avatarState.mood, avatarState.color_temperature); |
| gradient.addColorStop(0, `rgba(${color.r}, ${color.g}, ${color.b}, ${0.1 * avatarState.luminosity})`); |
| gradient.addColorStop(1, 'rgba(0, 0, 0, 0)'); |
| ctx.fillStyle = gradient; |
| ctx.fillRect(cx - 150, cy - 150, 300, 300); |
| |
| requestAnimationFrame(render); |
| } |
| requestAnimationFrame(render); |
| |
| // ββ Fetch avatar state from Python server ββ |
| async function fetchState() { |
| try { |
| const resp = await fetch('/avatar_state'); |
| if (resp.ok) { |
| avatarState = await resp.json(); |
| document.getElementById('mood').textContent = avatarState.mood; |
| document.getElementById('pos').textContent = |
| `(${avatarState.position[0].toFixed(1)}, ${avatarState.position[1].toFixed(1)}, ${avatarState.position[2].toFixed(1)})`; |
| document.getElementById('tier').textContent = avatarState.metabolic_tier; |
| } |
| } catch (e) { |
| // Server not available β keep running with last state |
| } |
| } |
| setInterval(fetchState, 100); // 10fps state update (render is 60fps) |
| </script> |
| </body> |
| </html> |
| """ |
|
|
|
|
| class AvatarRenderer: |
| """ |
| Serves the WebGL avatar renderer and provides the avatar state |
| to the browser via a simple HTTP endpoint. |
| |
| Usage: |
| renderer = AvatarRenderer(port=8888) |
| renderer.start() # starts HTTP server in background |
| # The renderer reads from the AvatarController and serves |
| # the state at http://localhost:8888/avatar_state |
| # The browser opens http://localhost:8888/ to see Nima |
| """ |
|
|
| def __init__(self, |
| controller: AvatarController, |
| port: int = 8888, |
| host: str = '0.0.0.0', |
| ) -> None: |
| self.controller = controller |
| self.host = host |
| self.port = port |
| self._server = None |
| self._thread = None |
| self._running = False |
|
|
| def start(self) -> bool: |
| """Start the HTTP server in a background thread.""" |
| try: |
| from http.server import HTTPServer, BaseHTTPRequestHandler |
| import threading |
|
|
| class AvatarHandler(BaseHTTPRequestHandler): |
| def __init__(self, controller, *args, **kwargs): |
| self.controller = controller |
| super().__init__(*args, **kwargs) |
|
|
| def do_GET(self): |
| if self.path == '/avatar_state': |
| state = self.controller.get_state_dict() |
| self.send_response(200) |
| self.send_header('Content-Type', 'application/json') |
| self.send_header('Access-Control-Allow-Origin', '*') |
| self.end_headers() |
| self.wfile.write(json.dumps(state).encode()) |
| elif self.path == '/' or self.path == '/index.html': |
| self.send_response(200) |
| self.send_header('Content-Type', 'text/html') |
| self.end_headers() |
| self.wfile.write(WEBGL_RENDERER_HTML.encode()) |
| else: |
| self.send_response(404) |
| self.end_headers() |
|
|
| def log_message(self, format, *args): |
| pass |
|
|
| |
| |
| |
| |
| import functools |
| handler_with_controller = functools.partial(AvatarHandler, self.controller) |
|
|
| self._server = HTTPServer((self.host, self.port), handler_with_controller) |
| self._thread = threading.Thread(target=self._server.serve_forever, daemon=True) |
| self._thread.start() |
| self._running = True |
| display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host |
| logger.info("[AvatarRenderer] serving on http://%s:%d", display_host, self.port) |
| return True |
| except Exception as e: |
| logger.warning("[AvatarRenderer] failed to start: %s", e) |
| return False |
|
|
| def stop(self) -> None: |
| if self._server: |
| self._server.shutdown() |
| self._server = None |
| self._running = False |
|
|
| @property |
| def is_running(self) -> bool: |
| return self._running |
|
|
| def get_url(self) -> str: |
| display_host = 'localhost' if self.host in ('0.0.0.0', '') else self.host |
| return f"http://{display_host}:{self.port}/" |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(message)s") |
|
|
| print("=== Nima Avatar Renderer β Self Test ===\n") |
|
|
| controller = AvatarController() |
| renderer = AvatarRenderer(controller, port=8888) |
|
|
| |
| if renderer.start(): |
| print(f"Avatar renderer running at: {renderer.get_url()}") |
| print(f"Open this URL in your browser to see Nima.\n") |
|
|
| |
| emotions = [ |
| ("neutral", 0.0, 0.3, "FLOW"), |
| ("happy", 0.8, 0.6, "FLOW"), |
| ("sad", -0.7, 0.2, "FLOW"), |
| ("fearful", -0.5, 0.9, "PEAK"), |
| ] |
|
|
| for label, valence, arousal, tier in emotions: |
| emotion = type("Emotion", (), { |
| "valence": valence, |
| "arousal": arousal, |
| "label": label, |
| })() |
| controller.update( |
| position=(2.0 + valence, 2.0, 0.0), |
| emotion=emotion, |
| metabolic_tier=tier, |
| ) |
| state = controller.get_state_dict() |
| print(f" Mood: {state['mood']:12s} " |
| f"Luminosity: {state['luminosity']:.2f} " |
| f"Color temp: {state['color_temperature']:.2f} " |
| f"Tier: {state['metabolic_tier']}") |
| time.sleep(1) |
|
|
| print(f"\nRenderer is serving. Open {renderer.get_url()} in a browser.") |
| print("Press Ctrl+C to stop.") |
|
|
| try: |
| while True: |
| |
| controller.update() |
| time.sleep(0.1) |
| except KeyboardInterrupt: |
| print("\nStopping...") |
| renderer.stop() |
| else: |
| print("Failed to start renderer.") |
|
|
| print("\n=== Avatar self-test PASSED ===") |
|
|