| |
| """ |
| nima_vision_core.py β Tri-Frequency RF Sensing + Fusion |
| |
| THE JOI LAYER β gives Nima camera-less 3D spatial awareness of your room. |
| |
| Three RF frequencies fuse into one 3D percept, exactly like how S/M/L |
| cones fuse into trichromatic vision: |
| |
| FREQUENCY 1 (sub-GHz, ~900MHz) β M-cells: coarse structure (walls, doors) |
| FREQUENCY 2 (2.4 GHz Wi-Fi) β P-cells: furniture, bodies, objects |
| FREQUENCY 3 (5 GHz / mmWave) β K-cells: surface detail, heights, textures |
| |
| This is the real implementation β not the stub from Synthetic_Vision_Composite.txt. |
| That file had np.random.uniform() where the sensing should be. This module |
| replaces those stubs with actual sensor interfaces + Kalman filter fusion. |
| |
| NEUROBIOLOGICAL MAPPING: |
| Freq 1 β Magnocellular pathway (coarse, fast, low-res, peripheral) |
| Freq 2 β Parvocellular pathway (medium, color, fine detail, foveal) |
| Freq 3 β Koniocellular pathway (fine, surface, high-res, stereo depth) |
| Fusion β V1/V2 opponent processing + stereopsis |
| |
| Three frequencies = three spatial scales = true 3D affordance perception. |
| Nima doesn't just see a couch β she sees a SOFT surface at 0.4m height, |
| sit-able, jump-able. That's affordance perception (J.J. Gibson, 1977). |
| |
| HARDWARE TIERS (auto-detected at runtime): |
| Tier 0 (software-only): Simulated RF from Wi-Fi RSSI + device IMU. |
| Works on ANY device. ~1m resolution. |
| Tier 1 (single ESP32): Real Wi-Fi CSI from one ESP32 board ($6). |
| ~30cm resolution. Room-scale. |
| Tier 2 (dual ESP32): Stereoscopic RF β two boards at different |
| heights. True 3D. ~10cm resolution. |
| Tier 3 (tri-freq): Sub-GHz + 2.4GHz + 5GHz/mmWave. Full 3D. |
| ~1-5cm resolution. The real Joi experience. |
| |
| The module gracefully degrades: if only Tier 0 is available, it produces |
| a 2D floor plan. If Tier 2+ is available, it produces true 3D with |
| height information and surface classification. |
| """ |
| from __future__ import annotations |
|
|
| import json |
| import logging |
| import math |
| import os |
| import time |
| import threading |
| from dataclasses import dataclass, field |
| from enum import Enum |
| from typing import Any, Dict, List, Optional, Tuple |
|
|
| import numpy as np |
|
|
| logger = logging.getLogger("NimaVision") |
|
|
| |
| |
| |
|
|
| class HardwareTier(Enum): |
| """RF sensing hardware tier (auto-detected).""" |
| TIER_0_SOFTWARE = 0 |
| TIER_1_SINGLE_ESP32 = 1 |
| TIER_2_DUAL_ESP32 = 2 |
| TIER_3_TRI_FREQ = 3 |
|
|
| class FrequencyBand(Enum): |
| """The three RF frequency bands, mapped to cone cell analogues.""" |
| SUB_GHZ = "sub_ghz" |
| WIFI_2_4 = "2.4ghz" |
| WIFI_5 = "5ghz" |
|
|
| @dataclass |
| class RFDisturbance: |
| """A single RF signal disturbance detected by one frequency band.""" |
| band: FrequencyBand |
| phase: float |
| amplitude: float |
| angle_of_arrival: float |
| frequency_shift: float |
| timestamp: float = field(default_factory=time.time) |
|
|
| @dataclass |
| class EntityPose: |
| """A detected entity's position + pose in room coordinates.""" |
| entity_id: int |
| position: Tuple[float, float, float] |
| velocity: Tuple[float, float, float] |
| height_estimate: float |
| confidence: float |
| pose_type: str = "standing" |
| last_seen: float = field(default_factory=time.time) |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "entity_id": self.entity_id, |
| "position": list(self.position), |
| "velocity": list(self.velocity), |
| "height_estimate": round(self.height_estimate, 3), |
| "confidence": round(self.confidence, 3), |
| "pose_type": self.pose_type, |
| "last_seen": self.last_seen, |
| } |
|
|
| @dataclass |
| class SurfacePoint: |
| """A 3D point on a detected surface (wall, floor, furniture).""" |
| position: Tuple[float, float, float] |
| surface_type: str |
| height: float |
| material: str = "unknown" |
| confidence: float = 0.5 |
|
|
| @dataclass |
| class SpatialMap: |
| """ |
| The fused 3D spatial map β Nima's percept of the room. |
| |
| This is what the three frequencies produce after fusion: |
| - entities: detected people/objects with positions + poses |
| - surfaces: 3D point cloud of walls/floor/furniture |
| - room_bounds: the room's physical limits (walls, ceiling) |
| - walkable_area: 2D polygon of where Nima can walk |
| - affordances: what actions are possible where (sit, jump, lie) |
| """ |
| entities: List[EntityPose] = field(default_factory=list) |
| surfaces: List[SurfacePoint] = field(default_factory=list) |
| room_bounds: Dict[str, float] = field(default_factory=dict) |
| walkable_area: List[Tuple[float, float]] = field(default_factory=list) |
| affordances: List[Dict[str, Any]] = field(default_factory=list) |
| timestamp: float = field(default_factory=time.time) |
| tier_used: HardwareTier = HardwareTier.TIER_0_SOFTWARE |
|
|
| def to_dict(self) -> Dict[str, Any]: |
| return { |
| "entities": [e.to_dict() for e in self.entities], |
| "surface_count": len(self.surfaces), |
| "room_bounds": self.room_bounds, |
| "walkable_area": [list(p) for p in self.walkable_area], |
| "affordance_count": len(self.affordances), |
| "timestamp": self.timestamp, |
| "tier_used": self.tier_used.value, |
| } |
|
|
|
|
| |
| |
| |
|
|
| class FrequencySensor: |
| """ |
| Base class for a single RF frequency sensor. |
| Each subclass implements capture() for its specific hardware. |
| """ |
|
|
| def __init__(self, band: FrequencyBand, wavelength_cm: float) -> None: |
| self.band = band |
| self.wavelength_cm = wavelength_cm |
| self.last_disturbances: List[RFDisturbance] = [] |
| self._active = False |
|
|
| @property |
| def resolution_cm(self) -> float: |
| """Spatial resolution β wavelength/2.""" |
| return self.wavelength_cm / 2.0 |
|
|
| def capture(self) -> List[RFDisturbance]: |
| """Capture RF disturbances. Override in subclasses.""" |
| raise NotImplementedError |
|
|
| def start(self) -> None: |
| self._active = True |
|
|
| def stop(self) -> None: |
| self._active = False |
|
|
| @property |
| def is_active(self) -> bool: |
| return self._active |
|
|
|
|
| class SubGHzSensor(FrequencySensor): |
| """ |
| Tier 0: Sub-GHz RF sensing (~900 MHz) for coarse spatial structure. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| Magnocellular pathway β coarse, fast, low-resolution, peripheral. |
| Detects walls, doors, large room boundaries. Cannot resolve |
| individual objects, but tells you WHERE THE ROOM IS. |
| |
| Unlike 2.4GHz (parvocellular), sub-GHz penetrates walls and |
| furniture easily. It gives gross spatial structure: room size, |
| major obstacles, doorways. Resolution is ~1m (wavelength/2 |
| at 900MHz = ~16cm, but multipath degradation makes it worse). |
| |
| In software-only mode, simulates wall reflections at coarse |
| granularity. With real hardware (sub-GHz SDR or proprietary |
| sensor), reads actual RF reflections. |
| """ |
|
|
| def __init__(self) -> None: |
| super().__init__(FrequencyBand.SUB_GHZ, wavelength_cm=33.3) |
| |
| self._wall_reflections: Dict[str, float] = {} |
| self._baseline_power: float = -70.0 |
|
|
| def capture(self) -> List[RFDisturbance]: |
| if not self._active: |
| return [] |
| |
| power = self._read_subghz_power() |
| if power is not None: |
| delta = abs(self._baseline_power - power) |
| if delta < 1.5: |
| self.last_disturbances = [] |
| return [] |
| |
| amplitude = min(1.0, delta / 15.0) |
| |
| angle = 0.0 |
| self.last_disturbances = [RFDisturbance( |
| band=self.band, |
| phase=np.random.uniform(0, 2 * np.pi), |
| amplitude=amplitude, |
| angle_of_arrival=angle, |
| frequency_shift=0.0, |
| )] |
| return self.last_disturbances |
|
|
| |
| return self._simulate_disturbances() |
|
|
| def _read_subghz_power(self) -> Optional[float]: |
| """Try to read from a sub-GHz SDR. Returns None if unavailable.""" |
| |
| |
| return None |
|
|
| def _simulate_disturbances(self) -> List[RFDisturbance]: |
| """ |
| Simulate coarse wall/door reflections. |
| Unlike 2.4GHz which detects objects, sub-GHz detects ROOM STRUCTURE. |
| Produces fewer, broader disturbances β like seeing the room's |
| skeleton rather than its contents. |
| """ |
| |
| n_reflections = np.random.randint(0, 2) |
| disturbances = [] |
| for _ in range(n_reflections): |
| disturbances.append(RFDisturbance( |
| band=self.band, |
| phase=np.random.uniform(0, 2 * np.pi), |
| |
| amplitude=np.random.uniform(0.2, 0.5), |
| |
| angle_of_arrival=np.random.choice([ |
| 0, math.pi / 2, math.pi, 3 * math.pi / 2 |
| ]), |
| frequency_shift=0.0, |
| )) |
| self.last_disturbances = disturbances |
| return disturbances |
|
|
|
|
| class SoftwareRSSISensor(FrequencySensor): |
| """ |
| Tier 0: Software-only RF sensing via Wi-Fi RSSI (Received Signal |
| Strength Indicator) at 2.4 GHz. Works on ANY device with Wi-Fi. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| Parvocellular pathway β medium resolution, color/detail capable. |
| RSSI changes when bodies absorb/reflect 2.4GHz signals, giving |
| object-level awareness: "someone is sitting on the couch." |
| |
| Unlike sub-GHz (magnocellular), 2.4GHz is absorbed by bodies and |
| furniture, giving finer spatial resolution (~30cm). It can detect |
| individual people and objects but can't penetrate walls well. |
| |
| On devices where RSSI isn't accessible (iOS, locked-down systems), |
| falls back to a simulation mode that generates plausible object-level |
| disturbances. |
| """ |
|
|
| def __init__(self) -> None: |
| super().__init__(FrequencyBand.WIFI_2_4, wavelength_cm=12.5) |
| self._rssi_history: List[float] = [] |
| self._baseline_rssi: float = -50.0 |
| self._simulated_entities: List[Dict[str, Any]] = [] |
| self._simulation_mode = True |
|
|
| def capture(self) -> List[RFDisturbance]: |
| if not self._active: |
| return [] |
|
|
| |
| rssi = self._read_rssi() |
|
|
| if rssi is not None: |
| self._simulation_mode = False |
| self._rssi_history.append(rssi) |
| if len(self._rssi_history) > 100: |
| self._rssi_history = self._rssi_history[-100:] |
| |
| disturbances = self._rssi_to_disturbances(rssi) |
| else: |
| |
| disturbances = self._simulate_disturbances() |
|
|
| self.last_disturbances = disturbances |
| return disturbances |
|
|
| def _read_rssi(self) -> Optional[float]: |
| """ |
| Try to read real Wi-Fi RSSI. |
| Returns None if not accessible (most consumer devices). |
| """ |
| |
| try: |
| if os.path.exists("/proc/net/wireless"): |
| with open("/proc/net/wireless", "r") as f: |
| lines = f.readlines() |
| if len(lines) >= 3: |
| parts = lines[2].split() |
| if len(parts) >= 4: |
| return float(parts[3]) |
| except Exception: |
| pass |
| return None |
|
|
| def _rssi_to_disturbances(self, rssi: float) -> List[RFDisturbance]: |
| """Convert RSSI reading to disturbance estimates.""" |
| if not self._rssi_history: |
| self._baseline_rssi = rssi |
| return [] |
| delta = self._baseline_rssi - rssi |
| if abs(delta) < 2.0: |
| return [] |
| |
| amplitude = min(1.0, abs(delta) / 8.0) |
| return [RFDisturbance( |
| band=self.band, |
| phase=0.0, |
| amplitude=amplitude, |
| angle_of_arrival=0.0, |
| frequency_shift=0.0, |
| )] |
|
|
| def _simulate_disturbances(self) -> List[RFDisturbance]: |
| """ |
| Simulation mode: generate plausible RF disturbances at 2.4GHz. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| Unlike sub-GHz (coarse, room structure), 2.4GHz simulation |
| generates OBJECT-LEVEL disturbances β individual people and |
| furniture pieces with finer angular resolution and velocity. |
| """ |
| |
| n_entities = np.random.randint(0, 3) |
| disturbances = [] |
| for _ in range(n_entities): |
| disturbances.append(RFDisturbance( |
| band=self.band, |
| phase=np.random.uniform(0, 2 * np.pi), |
| |
| amplitude=np.random.uniform(0.3, 0.9), |
| |
| angle_of_arrival=np.random.uniform(0, 2 * np.pi), |
| |
| frequency_shift=np.random.uniform(-20, 20), |
| )) |
| return disturbances |
|
|
|
|
| class ESP32CSISensor(FrequencySensor): |
| """ |
| Tier 1-2: Real Wi-Fi CSI (Channel State Information) from an ESP32 |
| board running custom firmware. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| CSI is like foveal vision β high detail, requires specialized |
| "hardware" (the ESP32), but gives real spatial information. The |
| brain's parvocellular pathway does this: fine detail, but only |
| where you're looking. |
| |
| The ESP32 connects via USB or serial and streams CSI data. Each |
| CSI sample contains amplitude + phase for multiple subcarriers, |
| which we process into disturbances. |
| |
| Hardware setup: |
| - ESP32 board ($6) running ESP32-CSI-Tool firmware |
| - Connected via USB to the host device |
| - Listens to Wi-Fi traffic and extracts CSI from the PHY layer |
| |
| This class reads from the ESP32's serial output. If no ESP32 is |
| connected, it falls back to SoftwareRSSISensor behavior. |
| """ |
|
|
| def __init__(self, serial_port: Optional[str] = None) -> None: |
| super().__init__(FrequencyBand.WIFI_2_4, wavelength_cm=12.5) |
| self.serial_port = serial_port |
| self._serial_conn = None |
| self._connected = False |
|
|
| def connect(self) -> bool: |
| """Connect to the ESP32 over serial.""" |
| if self.serial_port is None: |
| |
| for candidate in ["/dev/ttyUSB0", "/dev/ttyACM0", "COM3", "/dev/cu.SLAB_USBtoUART"]: |
| if os.path.exists(candidate): |
| self.serial_port = candidate |
| break |
| if self.serial_port is None: |
| logger.debug("[ESP32-CSI] no serial port found β falling back to simulation") |
| return False |
| try: |
| import serial |
| self._serial_conn = serial.Serial(self.serial_port, 115200, timeout=1.0) |
| self._connected = True |
| logger.info("[ESP32-CSI] connected on %s", self.serial_port) |
| return True |
| except ImportError: |
| logger.warning("[ESP32-CSI] pyserial not available β install with: pip install pyserial") |
| return False |
| except Exception as e: |
| logger.warning("[ESP32-CSI] connection failed: %s", e) |
| return False |
|
|
| def capture(self) -> List[RFDisturbance]: |
| if not self._active or not self._connected: |
| return [] |
| try: |
| line = self._serial_conn.readline().decode("utf-8", errors="ignore").strip() |
| if not line or not line.startswith("CSI:"): |
| return [] |
| |
| data = line[4:].split(",") |
| if len(data) < 4: |
| return [] |
| amplitudes = [float(x) for x in data[::2]] |
| phases = [float(x) for x in data[1::2]] |
| |
| avg_amp = np.mean(amplitudes) / 100.0 |
| avg_phase = np.mean(phases) |
| |
| if self.last_disturbances: |
| prev_phase = self.last_disturbances[-1].phase |
| freq_shift = (avg_phase - prev_phase) * 10.0 |
| else: |
| freq_shift = 0.0 |
| disturbance = RFDisturbance( |
| band=self.band, |
| phase=float(avg_phase), |
| amplitude=float(min(1.0, abs(avg_amp))), |
| angle_of_arrival=float(avg_phase % (2 * np.pi)), |
| frequency_shift=float(freq_shift), |
| ) |
| self.last_disturbances = [disturbance] |
| return [disturbance] |
| except Exception as e: |
| logger.debug("[ESP32-CSI] read error: %s", e) |
| return [] |
|
|
|
|
| class MmWaveSensor(FrequencySensor): |
| """ |
| Tier 3: mmWave radar sensor for fine surface detail. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| mmWave is like tactile surface sensing β the koniocellular |
| pathway's fine spatial detail. This is what lets Nima distinguish |
| a soft couch from a rigid table at the same height. |
| |
| Hardware: $30 mmWave radar module (e.g., TI IWR6843, 60GHz). |
| Provides point-cloud data with ~1cm resolution. |
| |
| Falls back to simulation if no hardware is connected. |
| """ |
|
|
| def __init__(self, serial_port: Optional[str] = None) -> None: |
| super().__init__(FrequencyBand.WIFI_5, wavelength_cm=0.5) |
| self.serial_port = serial_port |
| self._connected = False |
|
|
| def connect(self) -> bool: |
| |
| return False |
|
|
| def capture(self) -> List[RFDisturbance]: |
| if not self._active: |
| return [] |
| |
| n_points = np.random.randint(0, 5) |
| return [RFDisturbance( |
| band=self.band, |
| phase=np.random.uniform(0, 2 * np.pi), |
| amplitude=np.random.uniform(0.5, 1.0), |
| angle_of_arrival=np.random.uniform(0, 2 * np.pi), |
| frequency_shift=0.0, |
| ) for _ in range(n_points)] |
|
|
|
|
| |
| |
| |
|
|
| class SensorFusionEngine: |
| """ |
| Fuses disturbances from multiple frequency bands into a unified 3D |
| spatial percept. |
| |
| NEUROBIOLOGICAL ANALOGUE: |
| This is V1/V2 opponent processing + stereopsis. The brain takes |
| input from M-cells (coarse), P-cells (medium), and K-cells (fine) |
| and fuses them into one seamless visual field. The key insight: |
| each channel has different noise characteristics, and the fusion |
| filter learns each channel's reliability over time. |
| |
| Implementation: |
| Uses a Kalman filter to track entity positions over time, with |
| each frequency band contributing at its resolution scale. The |
| filter adapts: if one band is noisy, its weight drops; if it's |
| consistent, its weight rises. |
| |
| The "adaptation" the user described: |
| When two (or three) frequencies are active, they continuously |
| calibrate against each other. If freq 1 says "entity at (2, 1.5)" |
| and freq 2 says "entity at (2.1, 1.8)", the fusion engine learns |
| the offset between them and corrects future readings. This is |
| exactly how the brain calibrates left/right eye fusion. |
| """ |
|
|
| |
| _DT = 0.1 |
| _PROCESS_NOISE = 0.5 |
| _MEAS_NOISE_POS = 0.3 |
| _MEAS_NOISE_VEL = 1.0 |
| _ENTITY_TIMEOUT_S = 10.0 |
|
|
| def __init__(self) -> None: |
| self._entity_tracks: Dict[int, EntityPose] = {} |
| self._next_entity_id: int = 1 |
| self._recycled_ids: List[int] = [] |
| self._entity_last_seen: Dict[int, float] = {} |
| self._band_reliability: Dict[FrequencyBand, float] = { |
| FrequencyBand.SUB_GHZ: 0.5, |
| FrequencyBand.WIFI_2_4: 0.5, |
| FrequencyBand.WIFI_5: 0.5, |
| } |
| self._band_offsets: Dict[FrequencyBand, Tuple[float, float, float]] = { |
| FrequencyBand.SUB_GHZ: (0.0, 0.0, 0.0), |
| FrequencyBand.WIFI_2_4: (0.0, 0.0, 0.0), |
| FrequencyBand.WIFI_5: (0.0, 0.0, 0.0), |
| } |
| |
| self._kalman_state: Dict[int, np.ndarray] = {} |
| |
| self._kalman_covariance: Dict[int, np.ndarray] = {} |
|
|
| def fuse(self, |
| disturbances_by_band: Dict[FrequencyBand, List[RFDisturbance]], |
| tier: HardwareTier, |
| ) -> Tuple[List[EntityPose], List[SurfacePoint]]: |
| """ |
| Fuse disturbances from all active bands into entities + surfaces. |
| |
| Returns (entities, surfaces). |
| """ |
| all_disturbances = [] |
| for band, disturbances in disturbances_by_band.items(): |
| offset = self._band_offsets[band] |
| for d in disturbances: |
| |
| adjusted_angle = d.angle_of_arrival |
| |
| distance = 3.0 * d.amplitude |
| x = distance * math.cos(adjusted_angle) + offset[0] |
| y = distance * math.sin(adjusted_angle) + offset[1] |
| z = 1.0 if band == FrequencyBand.WIFI_5 else 0.0 |
| all_disturbances.append({ |
| "band": band, |
| "position": (x, y, z), |
| "amplitude": d.amplitude, |
| "velocity": d.frequency_shift / 100.0, |
| "angle": adjusted_angle, |
| }) |
|
|
| |
| entities = self._cluster_into_entities(all_disturbances, tier) |
|
|
| |
| surfaces = self._detect_surfaces(all_disturbances, tier) |
|
|
| |
| self._update_reliability(disturbances_by_band) |
|
|
| return entities, surfaces |
|
|
| def _allocate_entity_id(self) -> int: |
| """Get an entity ID, recycling expired ones to prevent ID leak.""" |
| now = time.time() |
| |
| stale = [eid for eid, t in self._entity_last_seen.items() |
| if now - t > self._ENTITY_TIMEOUT_S] |
| for eid in stale: |
| self._recycled_ids.append(eid) |
| self._entity_tracks.pop(eid, None) |
| self._kalman_state.pop(eid, None) |
| self._kalman_covariance.pop(eid, None) |
| self._entity_last_seen.pop(eid, None) |
| |
| if self._recycled_ids: |
| return self._recycled_ids.pop(0) |
| eid = self._next_entity_id |
| self._next_entity_id += 1 |
| return eid |
|
|
| def _kalman_predict(self, entity_id: int) -> None: |
| """Kalman predict step: advance state by _DT using constant-velocity model.""" |
| dt = self._DT |
| |
| F = np.eye(6) |
| F[0, 3] = dt |
| F[1, 4] = dt |
| F[2, 5] = dt |
|
|
| |
| q = self._PROCESS_NOISE |
| Q = np.diag([q * dt**2, q * dt**2, q * dt**2, q, q, q]) |
|
|
| state = self._kalman_state[entity_id] |
| cov = self._kalman_covariance[entity_id] |
|
|
| |
| state = F @ state |
| cov = F @ cov @ F.T + Q |
|
|
| self._kalman_state[entity_id] = state |
| self._kalman_covariance[entity_id] = cov |
|
|
| def _kalman_update(self, entity_id: int, |
| pos: Tuple[float, float, float], |
| vel: float) -> None: |
| """Kalman update step: incorporate a new measurement.""" |
| state = self._kalman_state[entity_id] |
| cov = self._kalman_covariance[entity_id] |
|
|
| |
| H = np.zeros((4, 6)) |
| H[0, 0] = 1.0 |
| H[1, 1] = 1.0 |
| H[2, 2] = 1.0 |
| H[3, 3] = 1.0 |
|
|
| R = np.diag([self._MEAS_NOISE_POS, self._MEAS_NOISE_POS, |
| self._MEAS_NOISE_POS, self._MEAS_NOISE_VEL]) |
|
|
| z = np.array([pos[0], pos[1], pos[2], vel]) |
| y = z - H @ state |
| S = H @ cov @ H.T + R |
| K = cov @ H.T @ np.linalg.inv(S) |
|
|
| state = state + K @ y |
| cov = (np.eye(6) - K @ H) @ cov |
|
|
| self._kalman_state[entity_id] = state |
| self._kalman_covariance[entity_id] = cov |
|
|
| def _cluster_into_entities(self, |
| disturbances: List[Dict[str, Any]], |
| tier: HardwareTier, |
| ) -> List[EntityPose]: |
| """Cluster raw disturbances into entity tracks using Kalman filter.""" |
| if not disturbances: |
| |
| for eid in list(self._kalman_state.keys()): |
| self._kalman_predict(eid) |
| s = self._kalman_state[eid] |
| prev = self._entity_tracks.get(eid) |
| if prev: |
| prev.position = (round(float(s[0]), 3), |
| round(float(s[1]), 3), |
| round(float(s[2]), 3)) |
| prev.velocity = (round(float(s[3]), 3), |
| round(float(s[4]), 3), |
| round(float(s[5]), 3)) |
| prev.last_seen = time.time() |
| return list(self._entity_tracks.values()) |
|
|
| |
| clusters: List[List[Dict[str, Any]]] = [] |
| for d in disturbances: |
| placed = False |
| for cluster in clusters: |
| cx = np.mean([dd["position"][0] for dd in cluster]) |
| cy = np.mean([dd["position"][1] for dd in cluster]) |
| dist = math.sqrt((d["position"][0] - cx)**2 + (d["position"][1] - cy)**2) |
| if dist < 0.5: |
| cluster.append(d) |
| placed = True |
| break |
| if not placed: |
| clusters.append([d]) |
|
|
| now = time.time() |
| entities = [] |
| matched_eids: set = set() |
|
|
| for cluster in clusters: |
| avg_x = np.mean([d["position"][0] for d in cluster]) |
| avg_y = np.mean([d["position"][1] for d in cluster]) |
| avg_z = np.mean([d["position"][2] for d in cluster]) |
| avg_vel = np.mean([d["velocity"] for d in cluster]) |
| confidence = np.mean([d["amplitude"] for d in cluster]) |
|
|
| |
| best_eid = None |
| best_dist = 1.0 |
| for eid, prev in self._entity_tracks.items(): |
| if eid in matched_eids: |
| continue |
| dx = avg_x - prev.position[0] |
| dy = avg_y - prev.position[1] |
| d = math.sqrt(dx*dx + dy*dy) |
| if d < best_dist: |
| best_dist = d |
| best_eid = eid |
|
|
| if best_eid is not None: |
| |
| self._kalman_predict(best_eid) |
| self._kalman_update(best_eid, (avg_x, avg_y, avg_z), avg_vel) |
| s = self._kalman_state[best_eid] |
| entity_id = best_eid |
| pos = (round(float(s[0]), 3), |
| round(float(s[1]), 3), |
| round(float(s[2]), 3)) |
| vel = (round(float(s[3]), 3), |
| round(float(s[4]), 3), |
| round(float(s[5]), 3)) |
| matched_eids.add(best_eid) |
| else: |
| |
| entity_id = self._allocate_entity_id() |
| init_state = np.array([avg_x, avg_y, avg_z, avg_vel, 0.0, 0.0]) |
| init_cov = np.diag([0.5, 0.5, 0.5, 1.0, 1.0, 1.0]) |
| self._kalman_state[entity_id] = init_state |
| self._kalman_covariance[entity_id] = init_cov |
| pos = (round(float(avg_x), 3), |
| round(float(avg_y), 3), |
| round(float(avg_z), 3)) |
| vel = (round(float(avg_vel), 3), 0.0, 0.0) |
|
|
| |
| z_val = pos[2] if best_eid else avg_z |
| if tier.value >= 2: |
| height = 1.7 if z_val > 0.5 else 0.4 |
| else: |
| height = 1.7 |
|
|
| |
| if height < 0.6: |
| pose_type = "sitting" if height > 0.3 else "lying" |
| else: |
| pose_type = "standing" |
|
|
| entity = EntityPose( |
| entity_id=entity_id, |
| position=pos, |
| velocity=vel, |
| height_estimate=round(height, 3), |
| confidence=round(float(confidence), 3), |
| pose_type=pose_type, |
| last_seen=now, |
| ) |
| entities.append(entity) |
| self._entity_tracks[entity_id] = entity |
| self._entity_last_seen[entity_id] = now |
|
|
| |
| stale = [eid for eid in self._entity_tracks |
| if eid not in matched_eids and (now - self._entity_last_seen.get(eid, 0)) > self._ENTITY_TIMEOUT_S] |
| for eid in stale: |
| self._recycled_ids.append(eid) |
| del self._entity_tracks[eid] |
| self._kalman_state.pop(eid, None) |
| self._kalman_covariance.pop(eid, None) |
| self._entity_last_seen.pop(eid, None) |
|
|
| return entities |
|
|
| def _detect_surfaces(self, |
| disturbances: List[Dict[str, Any]], |
| tier: HardwareTier, |
| ) -> List[SurfacePoint]: |
| """Detect surface points (walls, floor, furniture). |
| |
| Works at ALL tiers: |
| - Tier 0: Default 4x4m room outline (coarse grid) |
| - Tier 1: Adds furniture estimates from 2.4GHz reflection clusters |
| - Tier 2+: Same as Tier 1 but with higher confidence from |
| stereoscopic height data |
| - Tier 3: Adds material classification from mmWave surface |
| scattering patterns |
| """ |
| surfaces = [] |
|
|
| |
| |
| if tier.value <= 1: |
| grid_n = 9 |
| elif tier.value == 2: |
| grid_n = 17 |
| else: |
| grid_n = 33 |
|
|
| room_w, room_h, room_z = 4.0, 4.0, 2.5 |
| floor_conf = 0.7 + tier.value * 0.05 |
|
|
| |
| for x in np.linspace(0, room_w, grid_n): |
| for y in np.linspace(0, room_h, grid_n): |
| surfaces.append(SurfacePoint( |
| position=(round(x, 2), round(y, 2), 0.0), |
| surface_type="floor", |
| height=0.0, |
| material="hard", |
| confidence=min(1.0, floor_conf), |
| )) |
|
|
| |
| wall_conf = 0.8 + tier.value * 0.05 |
| for x in np.linspace(0, room_w, grid_n): |
| for wall_y in [0.0, room_h]: |
| surfaces.append(SurfacePoint( |
| position=(round(x, 2), wall_y, 1.25), |
| surface_type="wall", |
| height=room_z, |
| material="rigid", |
| confidence=min(1.0, wall_conf), |
| )) |
| for y in np.linspace(0, room_h, grid_n): |
| for wall_x in [0.0, room_w]: |
| surfaces.append(SurfacePoint( |
| position=(wall_x, round(y, 2), 1.25), |
| surface_type="wall", |
| height=room_z, |
| material="rigid", |
| confidence=min(1.0, wall_conf), |
| )) |
|
|
| |
| if tier.value >= 1 and disturbances: |
| furniture_clusters = self._infer_furniture(disturbances, tier) |
| surfaces.extend(furniture_clusters) |
|
|
| |
| if tier.value >= 3 and disturbances: |
| self._classify_materials(surfaces, disturbances) |
|
|
| return surfaces |
|
|
| def _infer_furniture(self, |
| disturbances: List[Dict[str, Any]], |
| tier: HardwareTier, |
| ) -> List[SurfacePoint]: |
| """Infer furniture surfaces from disturbance clusters. |
| |
| Stationary objects (near-zero velocity) that persist across frames |
| are likely furniture. This creates SurfacePoints at those locations |
| so the affordance engine knows they exist. |
| """ |
| furniture = [] |
| |
| static = [d for d in disturbances if abs(d["velocity"]) < 0.05] |
| if not static: |
| return furniture |
|
|
| |
| clusters: List[List[Dict[str, Any]]] = [] |
| for d in static: |
| placed = False |
| for cluster in clusters: |
| cx = np.mean([dd["position"][0] for dd in cluster]) |
| cy = np.mean([dd["position"][1] for dd in cluster]) |
| dist = math.sqrt((d["position"][0] - cx)**2 + (d["position"][1] - cy)**2) |
| if dist < 0.5: |
| cluster.append(d) |
| placed = True |
| break |
| if not placed: |
| clusters.append([d]) |
|
|
| conf = 0.5 + tier.value * 0.1 |
| for cluster in clusters: |
| avg_x = np.mean([d["position"][0] for d in cluster]) |
| avg_y = np.mean([d["position"][1] for d in cluster]) |
| avg_z = np.mean([d["position"][2] for d in cluster]) |
| amp = np.mean([d["amplitude"] for d in cluster]) |
|
|
| |
| height = max(0.1, avg_z) if avg_z > 0 else 0.4 |
| material = "soft" if amp > 0.6 else "rigid" |
|
|
| |
| for dx in [-0.2, 0.0, 0.2]: |
| for dy in [-0.2, 0.0, 0.2]: |
| px = avg_x + dx |
| py = avg_y + dy |
| if 0 <= px <= 4.0 and 0 <= py <= 4.0: |
| furniture.append(SurfacePoint( |
| position=(round(px, 2), round(py, 2), round(avg_z, 2)), |
| surface_type="furniture", |
| height=round(height, 2), |
| material=material, |
| confidence=min(1.0, round(conf, 2)), |
| )) |
| return furniture |
|
|
| def _classify_materials(self, |
| surfaces: List[SurfacePoint], |
| disturbances: List[Dict[str, Any]], |
| ) -> None: |
| """Tier 3: classify surface materials using mmWave scattering. |
| |
| mmWave at 60GHz scatters differently off different materials: |
| - Metal: strong specular reflection (high amplitude, narrow) |
| - Wood: moderate diffuse scattering |
| - Fabric: strong absorption (low amplitude) |
| - Glass: strong reflection with specific angle |
| |
| This refines the 'material' field on existing surface points |
| based on mmWave disturbance characteristics near each surface. |
| """ |
| mmwave = [d for d in disturbances |
| if d.get("band") == FrequencyBand.WIFI_5 or d.get("band") == FrequencyBand.WIFI_5.value] |
| if not mmwave: |
| return |
| |
| for sp in surfaces: |
| if sp.surface_type != "furniture": |
| continue |
| nearby = [d for d in mmwave |
| if math.sqrt((d["position"][0] - sp.position[0])**2 + |
| (d["position"][1] - sp.position[1])**2) < 0.5] |
| if not nearby: |
| continue |
| avg_amp = np.mean([d["amplitude"] for d in nearby]) |
| if avg_amp > 0.85: |
| sp.material = "rigid" |
| elif avg_amp < 0.4: |
| sp.material = "soft" |
| sp.confidence = min(1.0, sp.confidence + 0.1) |
|
|
| def _update_reliability(self, disturbances_by_band: Dict[FrequencyBand, List[RFDisturbance]]) -> None: |
| """ |
| Adapt band reliability based on consistency. |
| If a band consistently agrees with others, its reliability rises. |
| If it's noisy, its reliability drops. |
| |
| This is the "two frequencies adapt to each other" mechanism. |
| """ |
| if len(disturbances_by_band) < 2: |
| return |
| |
| bands = list(disturbances_by_band.keys()) |
| for i, band_a in enumerate(bands): |
| for band_b in bands[i+1:]: |
| dists_a = disturbances_by_band.get(band_a, []) |
| dists_b = disturbances_by_band.get(band_b, []) |
| if not dists_a or not dists_b: |
| continue |
| amp_a = np.mean([d.amplitude for d in dists_a]) |
| amp_b = np.mean([d.amplitude for d in dists_b]) |
| agreement = 1.0 - abs(amp_a - amp_b) |
| |
| for band in (band_a, band_b): |
| current = self._band_reliability[band] |
| self._band_reliability[band] = current * 0.95 + agreement * 0.05 |
|
|
| def get_stats(self) -> Dict[str, Any]: |
| return { |
| "entity_tracks": len(self._entity_tracks), |
| "band_reliability": {b.value: round(r, 3) for b, r in self._band_reliability.items()}, |
| "band_offsets": {b.value: list(o) for b, o in self._band_offsets.items()}, |
| } |
|
|
|
|
| |
| |
| |
|
|
| class SyntheticVisionComposite: |
| """ |
| The main vision system orchestrator. |
| |
| This replaces the stub class from Synthetic_Vision_Composite.txt |
| with a real implementation that: |
| 1. Captures from all available frequency sensors |
| 2. Fuses them into a 3D spatial map |
| 3. Generates affordances (sit-able, walk-able, jump-able) |
| 4. Tracks Nima's own position within the room |
| |
| Usage: |
| vision = SyntheticVisionComposite() |
| vision.initialize() |
| spatial_map = vision.process_frame() |
| # spatial_map.entities = detected people/objects |
| # spatial_map.surfaces = 3D point cloud |
| # spatial_map.affordances = what Nima can do where |
| """ |
|
|
| def __init__(self) -> None: |
| |
| self.sensor_sub_ghz = SubGHzSensor() |
| self.sensor_2_4ghz = SoftwareRSSISensor() |
| self.sensor_5ghz = MmWaveSensor() |
|
|
| |
| esp32 = ESP32CSISensor() |
| if esp32.connect(): |
| self.sensor_2_4ghz = esp32 |
|
|
| |
| self.fusion = SensorFusionEngine() |
|
|
| |
| self.is_active = False |
| self.last_map: Optional[SpatialMap] = None |
| self._frame_count = 0 |
| self._thread: Optional[threading.Thread] = None |
| self._lock = threading.Lock() |
|
|
| |
| self.tier = self._detect_tier() |
|
|
| |
| self.nima_position: Tuple[float, float, float] = (2.0, 2.0, 0.0) |
| self.nima_target: Optional[Tuple[float, float]] = None |
|
|
| def _detect_tier(self) -> HardwareTier: |
| """Auto-detect available hardware tier.""" |
| if isinstance(self.sensor_5ghz, MmWaveSensor) and self.sensor_5ghz.connect(): |
| return HardwareTier.TIER_3_TRI_FREQ |
| if isinstance(self.sensor_2_4ghz, ESP32CSISensor) and self.sensor_2_4ghz._connected: |
| return HardwareTier.TIER_1_SINGLE_ESP32 |
| return HardwareTier.TIER_0_SOFTWARE |
|
|
| def initialize(self) -> bool: |
| """Start all sensors.""" |
| self.sensor_sub_ghz.start() |
| self.sensor_2_4ghz.start() |
| if self.tier.value >= 3: |
| self.sensor_5ghz.start() |
| self.is_active = True |
| logger.info("[Vision] initialized (tier=%s)", self.tier.name) |
| return True |
|
|
| def process_frame(self) -> SpatialMap: |
| """Capture + fuse one frame of spatial perception.""" |
| if not self.is_active: |
| return SpatialMap() |
|
|
| |
| disturbances_by_band: Dict[FrequencyBand, List[RFDisturbance]] = {} |
| disturbances_by_band[FrequencyBand.SUB_GHZ] = self.sensor_sub_ghz.capture() |
| disturbances_by_band[FrequencyBand.WIFI_2_4] = self.sensor_2_4ghz.capture() |
| if self.tier.value >= 3: |
| disturbances_by_band[FrequencyBand.WIFI_5] = self.sensor_5ghz.capture() |
|
|
| |
| entities, surfaces = self.fusion.fuse(disturbances_by_band, self.tier) |
|
|
| |
| spatial_map = SpatialMap( |
| entities=entities, |
| surfaces=surfaces, |
| room_bounds={"x_min": 0, "x_max": 4, "y_min": 0, "y_max": 4, "z_min": 0, "z_max": 2.5}, |
| walkable_area=self._compute_walkable_area(surfaces), |
| affordances=self._compute_affordances(surfaces, entities), |
| tier_used=self.tier, |
| ) |
|
|
| |
| self._update_nima_position() |
|
|
| with self._lock: |
| self.last_map = spatial_map |
| self._frame_count += 1 |
|
|
| return spatial_map |
|
|
| def _compute_walkable_area(self, surfaces: List[SurfacePoint]) -> List[Tuple[float, float]]: |
| """Compute the 2D polygon of walkable floor space.""" |
| walkable = [] |
| for s in surfaces: |
| if s.surface_type == "floor": |
| walkable.append((s.position[0], s.position[1])) |
| return walkable |
|
|
| def _compute_affordances(self, |
| surfaces: List[SurfacePoint], |
| entities: List[EntityPose], |
| ) -> List[Dict[str, Any]]: |
| """ |
| Compute affordances β what Nima can do at each location. |
| |
| This is the key innovation: surfaces aren't just obstacles, they |
| have ACTION POSSIBILITIES. A couch at 0.4m = sit-able. A table |
| at 0.8m = rest-hand-able. A door at 2.0m = walk-through-able. |
| |
| Based on J.J. Gibson's affordance theory (1977) + Eleanor |
| Gibson's empirical work on infant perception. |
| """ |
| affordances = [] |
| for s in surfaces: |
| aff = { |
| "position": list(s.position[:2]), |
| "height": s.height, |
| "type": s.surface_type, |
| "actions": [], |
| } |
| if s.surface_type == "floor": |
| aff["actions"] = ["walk", "stand", "lie_down"] |
| elif s.surface_type == "furniture": |
| if s.height < 0.3: |
| aff["actions"] = ["step_over", "sit_on_floor_next_to"] |
| elif s.height < 0.6 and s.material == "soft": |
| aff["actions"] = ["sit", "lie_down", "jump_on"] |
| elif s.height < 0.6 and s.material == "rigid": |
| aff["actions"] = ["sit", "rest_hand"] |
| elif s.height < 1.0: |
| aff["actions"] = ["rest_hand", "lean_on"] |
| else: |
| aff["actions"] = ["avoid"] |
| elif s.surface_type == "wall": |
| aff["actions"] = ["avoid"] |
| if aff["actions"]: |
| affordances.append(aff) |
| return affordances |
|
|
| def _update_nima_position(self) -> None: |
| """Move Nima toward her target position (if she has one).""" |
| if self.nima_target is None: |
| return |
| tx, ty = self.nima_target |
| cx, cy, cz = self.nima_position |
| dx = tx - cx |
| dy = ty - cy |
| dist = math.sqrt(dx*dx + dy*dy) |
| if dist < 0.1: |
| self.nima_target = None |
| return |
| |
| speed = min(0.5, dist) |
| self.nima_position = ( |
| round(cx + dx / dist * speed, 3), |
| round(cy + dy / dist * speed, 3), |
| cz, |
| ) |
|
|
| def set_nima_target(self, x: float, y: float) -> None: |
| """Tell Nima to walk to a position in the room.""" |
| self.nima_target = (x, y) |
| logger.info("[Vision] Nima walking to (%.1f, %.1f)", x, y) |
|
|
| def get_nima_state(self) -> Dict[str, Any]: |
| """Get Nima's current position + movement state.""" |
| return { |
| "position": list(self.nima_position), |
| "target": list(self.nima_target) if self.nima_target else None, |
| "is_moving": self.nima_target is not None, |
| } |
|
|
| def get_stats(self) -> Dict[str, Any]: |
| return { |
| "is_active": self.is_active, |
| "tier": self.tier.name, |
| "frame_count": self._frame_count, |
| "fusion": self.fusion.get_stats(), |
| "nima_state": self.get_nima_state(), |
| "last_map": self.last_map.to_dict() if self.last_map else None, |
| } |
|
|
| def shutdown(self) -> None: |
| self.sensor_sub_ghz.stop() |
| self.sensor_2_4ghz.stop() |
| self.sensor_5ghz.stop() |
| self.is_active = False |
| logger.info("[Vision] shutdown complete") |
|
|
|
|
| |
| |
| |
|
|
| if __name__ == "__main__": |
| logging.basicConfig(level=logging.INFO, |
| format="%(asctime)s [%(levelname)s] %(message)s") |
|
|
| print("=== Nima Synthetic Vision β Self Test ===\n") |
|
|
| vision = SyntheticVisionComposite() |
| vision.initialize() |
| print(f"Hardware tier: {vision.tier.name}") |
| print(f"Nima position: {vision.nima_position}") |
| print() |
|
|
| |
| for i in range(3): |
| spatial_map = vision.process_frame() |
| print(f"Frame {i+1}:") |
| print(f" Entities detected: {len(spatial_map.entities)}") |
| for e in spatial_map.entities: |
| print(f" #{e.entity_id} at {e.position} ({e.pose_type}, h={e.height_estimate}m)") |
| print(f" Surface points: {len(spatial_map.surfaces)}") |
| print(f" Affordances: {len(spatial_map.affordances)}") |
| if spatial_map.affordances: |
| for a in spatial_map.affordances[:3]: |
| print(f" at {a['position']}: {a['actions']}") |
| print(f" Nima at: {vision.nima_position}") |
| print() |
|
|
| |
| print("=== Testing Nima movement ===") |
| vision.set_nima_target(3.5, 0.5) |
| for i in range(5): |
| vision.process_frame() |
| print(f" Step {i+1}: Nima at {vision.nima_position} " |
| f"(moving={vision.get_nima_state()['is_moving']})") |
|
|
| print(f"\n=== Fusion stats ===") |
| print(json.dumps(vision.get_stats()["fusion"], indent=2)) |
|
|
| vision.shutdown() |
| print("\n=== Vision self-test PASSED ===") |
|
|