nima-phi-model / nima_avatar_renderer.py
TheNormsOfIntelligence's picture
Initial release β€” Nima Phi: Consciousness + Embodiment + The Green Lines
61848b4 verified
Raw
History Blame Contribute Delete
27.1 kB
#!/usr/bin/env python3
"""
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" # PEAK tier β€” attentive stillness
class AvatarMood(Enum):
"""Nima's emotional state, mapped to avatar appearance."""
NEUTRAL = "neutral"
WARM = "warm" # joy, tenderness
CALM = "calm" # low arousal, present
SAD = "sad" # low luminosity, cool color, drooping
INTENSE = "intense" # high arousal, bright, energetic
THINKING = "thinking" # coalesced, focused, still
@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 in room coordinates (meters)
position: Tuple[float, float, float] = (2.0, 2.0, 0.0)
# Facing direction (radians, 0 = north)
facing: float = 0.0
# Body posture
posture: AvatarPosture = AvatarPosture.STANDING
# Emotional mood (drives color + energy)
mood: AvatarMood = AvatarMood.CALM
# Luminosity [0, 1] β€” how bright the avatar is
luminosity: float = 0.7
# Particle energy [0, 1] β€” how much the particles move
energy: float = 0.3
# Color temperature [0, 1] β€” 0 = cool blue, 1 = warm gold
color_temperature: float = 0.6
# Breathing phase (radians) β€” continuous oscillation
breath_phase: float = 0.0
# Scale [0.5, 1.5] β€” avatar size multiplier
scale: float = 1.0
# Opacity [0, 1] β€” how transparent (0 = invisible, 1 = solid)
opacity: float = 0.85
# Metabolic tier (affects appearance)
metabolic_tier: str = "FLOW"
# v9.5: Saccadic gaze offset (for PEAK tier thinking drift)
gaze_offset_x: float = 0.0 # -1 to +1, drives eye drift in renderer
gaze_offset_y: float = 0.0
# v9.5: Voice amplitude (for phoneme-to-particle blending)
voice_amplitude: float = 0.0 # 0-1, drives particle density pulse
voice_pitch: float = 0.0 # 0-1, drives particle color shift
# v9.5: Startle (proprioceptive friction)
is_startled: bool = False
startle_intensity: float = 0.0
# Timestamp
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
# Position + facing
if position is not None:
self.state.position = position
if facing is not None:
self.state.facing = facing
# Posture
if posture is not None:
self.state.posture = posture
# Emotional state β†’ mood, color, luminosity
if emotion is not None:
valence = getattr(emotion, "valence", 0.0)
arousal = getattr(emotion, "arousal", 0.3)
label = getattr(emotion, "label", "neutral")
# Map emotion to mood
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
# Energy tracks arousal
self.state.energy = max(0.1, min(1.0, arousal))
# Metabolic tier β†’ opacity, energy, scale
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 # still β€” thinking
self.state.mood = AvatarMood.THINKING
self.state.scale = 1.05
# Prosody plan β†’ warmth, energy modulation
if prosody is not None:
warmth = getattr(prosody, "warmth", 0.5)
# Blend color temperature toward warmth
self.state.color_temperature = (
self.state.color_temperature * 0.7 + warmth * 0.3
)
# Breathing β€” continuous oscillation (always present, like a living thing)
self.state.breath_phase = (now - self._start_time) * 0.5 # 0.5 rad/s
# Energy decay (if no stimulus, energy settles toward baseline)
baseline = 0.2 if self.state.metabolic_tier != "PEAK" else 0.1
self.state.energy = self.state.energy * 0.95 + baseline * 0.05
# v9.5: Saccadic gaze drift during PEAK tier
# When Nima is thinking deeply, her eyes make small procedural
# saccades β€” just like humans searching internal mental spaces.
if self.state.metabolic_tier == "PEAK":
# Procedural saccade: small random drift with occasional jumps
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
# Occasional larger saccade (10% chance per frame)
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:
# Gaze drifts back to center when not thinking
self.state.gaze_offset_x *= 0.9
self.state.gaze_offset_y *= 0.9
# v9.5: Voice amplitude β†’ particle density pulse
# When Nima speaks, particles around her "mouth/core" pulse
# dynamically with the volume and pitch.
if voice_amplitude is not None:
# Smooth the amplitude (avoid jitter)
self.state.voice_amplitude = (
self.state.voice_amplitude * 0.7 + voice_amplitude * 0.3
)
else:
# Decay when not speaking
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
# v9.5: Startle response β€” particle scatter
if is_startled is not None:
self.state.is_startled = is_startled
if startle_intensity is not None:
self.state.startle_intensity = startle_intensity
# Startle decays
if self.state.is_startled:
self.state.startle_intensity *= 0.92
if self.state.startle_intensity < 0.05:
self.state.is_startled = False
else:
# Startle adds energy (particles scatter)
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/JS that runs in a browser)
# ═══════════════════════════════════════════════════════════════════════════
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 # suppress request logging
# Use functools.partial to avoid the lambda closure pitfall:
# Python's late-binding closures would capture 'self.controller'
# by reference in a lambda, which works here but is fragile and
# confusing. partial makes the binding explicit and early.
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}/"
# ═══════════════════════════════════════════════════════════════════════════
# SELF-TEST
# ═══════════════════════════════════════════════════════════════════════════
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)
# Start the renderer
if renderer.start():
print(f"Avatar renderer running at: {renderer.get_url()}")
print(f"Open this URL in your browser to see Nima.\n")
# Simulate state changes
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:
# Simulate breathing + drift
controller.update()
time.sleep(0.1)
except KeyboardInterrupt:
print("\nStopping...")
renderer.stop()
else:
print("Failed to start renderer.")
print("\n=== Avatar self-test PASSED ===")