Download ninja_env.py from MtnMCG/PCEI-Robotics: direct link, hf CLI and curl.
- Browser
- Download file 94.4 kB
-
https://huggingface.co/MtnMCG/PCEI-Robotics/resolve/main/ninja_env.py
- Command line
-
hf download hf://MtnMCG/PCEI-Robotics/ninja_env.py
-
curl -L -o ninja_env.py https://huggingface.co/MtnMCG/PCEI-Robotics/resolve/main/ninja_env.py
94.4 kB
| """ | |
| embodied_pcei/src/ninja_env.py | |
| High-performance physical simulation environment for the American Ninja Warrior course. | |
| Built on MuJoCo with native Apple Silicon performance. | |
| Features: | |
| - Articulated bipedal humanoid with 21 joint torque actuators. | |
| - Staged American Ninja Warrior course: | |
| * Stage 0: Flat sprint runway (0 to 8m) | |
| * Stage 1: Quad steps / angled stepping stones (8 to 18m) | |
| * Stage 2: Elevated balance beam (18 to 28m) | |
| * Stage 3: Hurdles & stepping barriers (28 to 38m) | |
| * Stage 4: Warped wall incline ramp & summit buzzer (38 to 48m) | |
| - Raycast Pseudo-Vision: forward height probes measuring terrain/obstacle topology. | |
| - Metabolic / Homeostatic Telemetry: lungs/stamina, stomach/hunger, heart/health. | |
| - Built-in offscreen renderer for live web dashboard streaming. | |
| """ | |
| import numpy as np | |
| import mujoco | |
| from pathlib import Path | |
| from typing import Dict, Tuple, Optional | |
| XML_PATH = Path(__file__).resolve().parent.parent / "models" / "ninja_course.xml" | |
| FLAT_ARENA_XML = Path(__file__).resolve().parent.parent / "models" / "flat_arena.xml" | |
| # Obstacle Stage Milestones along +X | |
| STAGE_MILESTONES = [ | |
| (8.0, "Stage 1: Quad Steps"), | |
| (18.0, "Stage 2: Balance Beam"), | |
| (28.0, "Stage 3: Hurdles"), | |
| (38.0, "Stage 4: Warped Wall"), | |
| (46.0, "Course Finish: Summit Buzzer"), | |
| ] | |
| # 18 Progressive Navigation Waypoints across the 48m Ninja Warrior Course | |
| # Format: (Target 3D Coordinate [X, Y, Z], Waypoint Label, Arrival Radius in meters) | |
| COURSE_WAYPOINTS = [ | |
| (np.array([3.0, 0.00, 1.33], dtype=np.float32), "Runway Accelerate 1", 1.2), | |
| (np.array([6.0, 0.00, 1.33], dtype=np.float32), "Runway Accelerate 2", 1.2), | |
| (np.array([8.0, 0.00, 1.33], dtype=np.float32), "Runway Exit Threshold", 1.0), | |
| (np.array([10.0, -0.60, 0.20], dtype=np.float32), "Stage 1: Quad Step 1 (Right)", 0.8), | |
| (np.array([12.2, 0.60, 0.30], dtype=np.float32), "Stage 1: Quad Step 2 (Left)", 0.8), | |
| (np.array([14.4, -0.50, 0.35], dtype=np.float32), "Stage 1: Quad Step 3 (Right)", 0.8), | |
| (np.array([16.6, 0.50, 0.40], dtype=np.float32), "Stage 1: Quad Step 4 (Left)", 0.8), | |
| (np.array([18.5, 0.00, 0.45], dtype=np.float32), "Platform Mid 1", 1.0), | |
| (np.array([20.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Entry", 0.6), | |
| (np.array([23.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Mid", 0.6), | |
| (np.array([26.5, 0.00, 0.50], dtype=np.float32), "Stage 2: Balance Beam Exit", 0.6), | |
| (np.array([28.5, 0.00, 0.50], dtype=np.float32), "Platform Mid 2", 1.0), | |
| (np.array([31.5, 0.00, 0.20], dtype=np.float32), "Stage 3: Hurdle 1", 0.9), | |
| (np.array([34.5, 0.00, 0.28], dtype=np.float32), "Stage 3: Hurdle 2", 0.9), | |
| (np.array([37.5, 0.00, 0.22], dtype=np.float32), "Stage 3: Hurdle 3", 0.9), | |
| (np.array([40.0, 0.00, 0.30], dtype=np.float32), "Stage 4: Warped Wall Approach",1.2), | |
| (np.array([42.5, 0.00, 0.70], dtype=np.float32), "Stage 4: Warped Wall Incline", 1.2), | |
| (np.array([47.5, 0.00, 2.45], dtype=np.float32), "Course Finish: Summit Buzzer", 0.6), | |
| ] | |
| # Translucent Neon Color Palette for Multi-Agent Ghost Swarm Rendering | |
| GHOST_PALETTE = [ | |
| np.array([0.2, 0.7, 1.0, 0.16], dtype=np.float32), # Neon Cyan | |
| np.array([0.7, 0.3, 1.0, 0.16], dtype=np.float32), # Electric Purple | |
| np.array([0.1, 0.9, 0.6, 0.16], dtype=np.float32), # Neon Emerald | |
| np.array([1.0, 0.55, 0.1, 0.16], dtype=np.float32), # Sunset Orange | |
| np.array([1.0, 0.2, 0.6, 0.16], dtype=np.float32), # Neon Pink | |
| np.array([0.35, 0.8, 1.0, 0.16], dtype=np.float32), # Sky Blue | |
| np.array([0.85, 0.95, 0.2, 0.16], dtype=np.float32),# Lime Volt | |
| ] | |
| # Canonical Reference Quiet Standing Equilibrium Pose (21 Actuated Joints) | |
| # Torso at Z=1.168m, spine vertical, knees naturally flexed at 0.05 rad (~3 deg), arms down at sides | |
| STAND_REF_QPOS = np.array([ | |
| 0.0, 0.0, 0.0, # abdomen_z, abdomen_y, abdomen_x | |
| 0.0, 0.0, -0.05, 0.05, -0.025, 0.0, # right_hip_x, z, y, knee, ankle_y, ankle_x | |
| 0.0, 0.0, -0.05, 0.05, -0.025, 0.0, # left_hip_x, z, y, knee, ankle_y, ankle_x | |
| 0.65, 0.0, 0.0, # right_shoulder_x, y, elbow (down at side) | |
| -0.65, 0.0, 0.0, # left_shoulder_x, y, elbow (down at side) | |
| ], dtype=np.float32) | |
| STAND_JOINT_WEIGHTS = np.array([ | |
| 1.5, 1.5, 1.5, # abdomen_z, abdomen_y, abdomen_x | |
| 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, # right leg (hip_x, hip_z, hip_y, knee, ankle_y, ankle_x) | |
| 1.5, 1.5, 1.5, 1.5, 1.5, 1.5, # left leg (hip_x, hip_z, hip_y, knee, ankle_y, ankle_x) | |
| 0.5, 0.5, 0.5, # right arm (shoulder_x, shoulder_y, elbow) | |
| 0.5, 0.5, 0.5 # left arm (shoulder_x, shoulder_y, elbow) | |
| ], dtype=np.float32) | |
| class NinjaEnv: | |
| def __init__(self, xml_path: Optional[str] = None, frame_skip: int = 5, arena_mode: bool = True, stream_mode: str = "cruise"): | |
| self.arena_mode = arena_mode | |
| self.stream_mode = stream_mode # "cruise", "stand", "getup", "reach" | |
| if xml_path is None: | |
| xml_path = str(FLAT_ARENA_XML if arena_mode else XML_PATH) | |
| self.model = mujoco.MjModel.from_xml_path(xml_path) | |
| self.data = mujoco.MjData(self.model) | |
| self.frame_skip = frame_skip | |
| self.dt = self.model.opt.timestep * frame_skip # 0.005 * 5 = 0.025s (40 Hz) | |
| self.n_actuators = self.model.nu # 21 | |
| self.n_qpos = self.model.nq # 28 (7 root + 21 joints) | |
| self.n_qvel = self.model.nv # 27 (6 root + 21 joints) | |
| # Solid-State Angular LiDAR Sensor Configuration | |
| # 16 vertical beams (pitch: -35 deg to +15 deg) x 32 horizontal beams (yaw: -45 deg to +45 deg) = 512 rays | |
| self.num_lidar_v = 16 | |
| self.num_lidar_h = 32 | |
| self.num_lidar_rays = self.num_lidar_v * self.num_lidar_h # 512 | |
| self.num_rays = self.num_lidar_rays # backwards-compatibility alias | |
| self.lidar_max_range = 10.0 # meters | |
| self.lidar_v_angles = np.radians(np.linspace(-35, 15, self.num_lidar_v)) | |
| self.lidar_h_angles = np.radians(np.linspace(-45, 45, self.num_lidar_h)) | |
| # Precompute unit direction vectors in head local coordinate frame | |
| # Forward = +X, Left = +Y, Up = +Z | |
| local_vectors = [] | |
| for pitch in self.lidar_v_angles: | |
| for yaw in self.lidar_h_angles: | |
| vx = np.cos(pitch) * np.cos(yaw) | |
| vy = np.cos(pitch) * np.sin(yaw) | |
| vz = np.sin(pitch) | |
| v = np.array([vx, vy, vz], dtype=np.float64) | |
| v /= np.linalg.norm(v) | |
| local_vectors.append(v) | |
| self.lidar_local_vectors = np.array(local_vectors, dtype=np.float64) # (128, 3) | |
| self.current_lidar_grid = np.zeros((self.num_lidar_v, self.num_lidar_h), dtype=np.float32) | |
| self._geomid_buf = np.zeros(1, dtype=np.int32) | |
| self._visor_offset = np.array([0.15, 0.0, 0.0], dtype=np.float64) | |
| # Metabolic internal states | |
| self.stamina = 1.0 # 0.0 to 1.0 | |
| self.health = 1.0 # 0.0 to 1.0 | |
| self.hunger = 0.0 # 0.0 to 1.0 | |
| # Milestone tracking & Course Waypoints | |
| self.milestones_reached = set() | |
| self.waypoints_reached = set() | |
| self.current_wp_idx = 0 | |
| self.furthest_x = 0.0 | |
| self.max_steps = 1000 | |
| # Flat Arena & Dynamic Random Goal Navigation State (Stream 4: Cruise) | |
| self.goal_pos = np.array([5.0, 0.0, 1.25], dtype=np.float32) | |
| self.goals_completed = 0 | |
| self.goal_arrival_radius = 2.0 # Wide capture radius (2.0m): easily clears by walking near target | |
| self.min_dist_to_goal = 5.0 | |
| self.prev_dist_to_goal = 5.0 | |
| self.steps_not_approaching = 0 | |
| self.transition_leeway_window = 90 # ~2.25s leeway (90 steps at 40Hz) for turning & realignment | |
| self.goal_transition_steps = 0 # No leeway on initial spawn (must advance immediately) | |
| # Stream 1: Quiet Standing Stillness State | |
| self.stand_still_steps = 0 | |
| self.stand_drift_steps = 0 | |
| self.stand_one_leg_steps = 0 | |
| # Stream 2: Fall Recovery State (Ragdoll & Curated Library) | |
| self.fall_state_type = "random_ragdoll" | |
| self.consecutive_upright_steps = 0 | |
| self.prev_z = 0.20 | |
| self.prev_head_z = 0.20 | |
| self.getup_stagnant_steps = 0 | |
| self.max_elevation_achieved = 0.20 | |
| self.max_head_elevation = 0.20 | |
| self.getup_goal_pos = np.array([0.0, 0.0, 1.168], dtype=np.float32) | |
| self.getup_success_count = 0 | |
| # Stream 3: Bilateral Hand Reaching State (Bolted Torso) | |
| self.active_reach_hand = "right" | |
| self.reach_target_pos = np.array([0.35, -0.25, 1.35], dtype=np.float32) | |
| self.reach_touches = 0 | |
| self.prev_reach_dist = 0.40 | |
| self.min_reach_dist = 0.40 | |
| self.steps_on_reach_target = 0 | |
| self.steps_receding_reach = 0 | |
| # Bilateral gait stance duty-cycle counters | |
| self.r_foot_contact_steps = 0 | |
| self.l_foot_contact_steps = 0 | |
| # Offscreen renderer instance (lazy-loaded) | |
| self._renderer = None | |
| # Cache body and geom IDs for fast lookups | |
| self.torso_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "torso") | |
| self.head_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "head") | |
| self.r_foot_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "right_foot") | |
| self.l_foot_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "left_foot") | |
| self.r_foot_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_foot_geom") | |
| self.l_foot_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_foot_geom") | |
| self.r_hand_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_hand") | |
| self.l_hand_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_hand") | |
| self.r_arm_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "right_lower_arm") | |
| self.l_arm_body_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_BODY, "left_lower_arm") | |
| # Cache leg joint qpos indices for athletic stance and gait monitoring | |
| self.r_knee_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_knee")] | |
| self.l_knee_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_knee")] | |
| self.r_hip_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_hip_y")] | |
| self.l_hip_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_hip_y")] | |
| self.r_ankle_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_ankle_y")] | |
| self.l_ankle_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_ankle_y")] | |
| # Cache abdomen joint qpos indices for core posture alignment | |
| self.abdomen_x_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_x")] | |
| self.abdomen_y_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_y")] | |
| self.abdomen_z_qpos = self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "abdomen_z")] | |
| # Cache arm joint qpos and dof velocity indices for bilateral reach | |
| self.arm_qpos_indices = [ | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_x")], | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_y")], | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_elbow")], | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_x")], | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_y")], | |
| self.model.jnt_qposadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_elbow")], | |
| ] | |
| self.arm_dof_indices = [ | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_x")], | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_shoulder_y")], | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "right_elbow")], | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_x")], | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_shoulder_y")], | |
| self.model.jnt_dofadr[mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_JOINT, "left_elbow")], | |
| ] | |
| # Cache collision geoms for physical impact detection (head, visor, chest, torso) | |
| # All geoms belonging to world body (body 0) are environmental surfaces: floor + all obstacles | |
| self.world_obstacle_geoms = {i for i in range(self.model.ngeom) if self.model.geom_bodyid[i] == 0} | |
| self.body_impact_geoms = { | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "torso_geom"), | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "chest_geom"), | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "head_geom"), | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "visor"), | |
| } | |
| # Cache humanoid geom indices (all geoms belonging to non-world bodies) for ghost rendering | |
| self.humanoid_geoms = [i for i in range(self.model.ngeom) if self.model.geom_bodyid[i] != 0] | |
| # Cache limb geoms for Get-Up multi-phase floor contact righting | |
| self.r_shin_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_shin_geom") | |
| self.l_shin_geom_id = mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_shin_geom") | |
| self.support_hand_geoms = { | |
| self.r_hand_geom_id, | |
| self.l_hand_geom_id, | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "right_larm_geom"), | |
| mujoco.mj_name2id(self.model, mujoco.mjtObj.mjOBJ_GEOM, "left_larm_geom"), | |
| } | |
| self.support_knee_geoms = {self.r_shin_geom_id, self.l_shin_geom_id} | |
| def _lock_torso_for_reach(self): | |
| """Bolts root torso, core spine, and legs in neutral athletic stance (Stream 3: Isolated Arm Motor Cortex).""" | |
| self.data.qpos[0] = 0.0 | |
| self.data.qpos[1] = 0.0 | |
| self.data.qpos[2] = 1.35 | |
| self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0]) # Upright quaternion | |
| self.data.qvel[0:6] = 0.0 # Zero base linear & angular velocity | |
| # Lock core spine neutral | |
| self.data.qpos[7:10] = 0.0 # abdomen_z, abdomen_y, abdomen_x | |
| # Lock legs in neutral supportive stance | |
| self.data.qpos[10:22] = 0.0 # zero all hip, knee, ankle | |
| self.data.qpos[self.r_knee_qpos] = 0.30 | |
| self.data.qpos[self.l_knee_qpos] = 0.30 | |
| self.data.qpos[self.r_ankle_qpos] = -0.10 | |
| self.data.qpos[self.l_ankle_qpos] = -0.10 | |
| self.data.qvel[6:21] = 0.0 # Zero core & lower body velocities (DOFs 6 to 20, preserving arm DOFs 21:27) | |
| def _sample_reach_target(self): | |
| """ | |
| Samples a randomized 3D reach goal via Forward Kinematics (FK) from the active arm's | |
| reachable joint space, mathematically guaranteeing 100.0% of targets are physically reachable (Stream 3). | |
| """ | |
| self.active_reach_hand = np.random.choice(["right", "left"]) | |
| # Save current posture | |
| saved_qpos = self.data.qpos.copy() | |
| target_pos = None | |
| # Sample joint angles from the arm's valid forward/lateral reachable envelope | |
| for _ in range(25): | |
| if self.active_reach_hand == "right": | |
| # Right arm envelope: shoulder roll/pitch and elbow flexion | |
| target_sx = float(np.random.uniform(-0.75, 0.40)) | |
| target_sy = float(np.random.uniform(-0.60, 0.85)) | |
| target_el = float(np.random.uniform(-1.10, 0.45)) | |
| self.data.qpos[self.arm_qpos_indices[0]] = target_sx # right_shoulder_x | |
| self.data.qpos[self.arm_qpos_indices[1]] = target_sy # right_shoulder_y | |
| self.data.qpos[self.arm_qpos_indices[2]] = target_el # right_elbow | |
| mujoco.mj_forward(self.model, self.data) | |
| cand_pos = self.data.geom_xpos[self.r_hand_geom_id].copy() | |
| else: | |
| # Left arm envelope: shoulder roll/pitch and elbow flexion | |
| target_sx = float(np.random.uniform(-0.40, 0.75)) | |
| target_sy = float(np.random.uniform(-0.60, 0.85)) | |
| target_el = float(np.random.uniform(-0.45, 1.10)) | |
| self.data.qpos[self.arm_qpos_indices[3]] = target_sx # left_shoulder_x | |
| self.data.qpos[self.arm_qpos_indices[4]] = target_sy # left_shoulder_y | |
| self.data.qpos[self.arm_qpos_indices[5]] = target_el # left_elbow | |
| mujoco.mj_forward(self.model, self.data) | |
| cand_pos = self.data.geom_xpos[self.l_hand_geom_id].copy() | |
| # Restore original pose to measure distance | |
| self.data.qpos[:] = saved_qpos | |
| mujoco.mj_forward(self.model, self.data) | |
| curr_h = self.data.geom_xpos[self.r_hand_geom_id] if self.active_reach_hand == "right" else self.data.geom_xpos[self.l_hand_geom_id] | |
| dist_to_cand = float(np.linalg.norm(curr_h - cand_pos)) | |
| target_pos = cand_pos | |
| # Ensure target is at least 0.16m away so the agent must actively articulate its arm | |
| if dist_to_cand >= 0.16: | |
| break | |
| # Always restore state cleanly | |
| self.data.qpos[:] = saved_qpos | |
| mujoco.mj_forward(self.model, self.data) | |
| self.reach_target_pos = target_pos.astype(np.float32) | |
| self.goal_pos = self.reach_target_pos.copy() | |
| def _apply_fall_state(self, pose_name: Optional[str] = None): | |
| """ | |
| Initializes agent in a randomized fall state with 360Β° randomized yaw, | |
| perturbed joints, physics contact settling, and a randomized standing destination goal (Stream 2). | |
| """ | |
| if pose_name is None: | |
| poses = ["prone", "supine", "lateral_left", "lateral_right", "kneeling_crumpled", "seated_slumped", "random_ragdoll"] | |
| pose_name = np.random.choice(poses) | |
| self.fall_state_type = pose_name | |
| self.data.qvel[:] = 0.0 | |
| # Random initial floor translation offset | |
| spawn_x = float(np.random.uniform(-0.4, 0.4)) | |
| spawn_y = float(np.random.uniform(-0.4, 0.4)) | |
| # Base orientation quaternion [w, x, y, z] per canonical pose | |
| if pose_name == "prone": | |
| # Lying on stomach: pitch 90 deg forward | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22]) | |
| q_base = np.array([0.7071, 0.0, 0.7071, 0.0]) | |
| self.data.qpos[self.r_knee_qpos] = 0.2 | |
| self.data.qpos[self.l_knee_qpos] = 0.2 | |
| elif pose_name == "supine": | |
| # Lying on back: pitch 90 deg backward | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22]) | |
| q_base = np.array([0.7071, 0.0, -0.7071, 0.0]) | |
| self.data.qpos[self.r_knee_qpos] = 0.3 | |
| self.data.qpos[self.l_knee_qpos] = 0.3 | |
| elif pose_name == "lateral_left": | |
| # Lying on left flank: roll 90 deg left | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22]) | |
| q_base = np.array([0.7071, -0.7071, 0.0, 0.0]) | |
| elif pose_name == "lateral_right": | |
| # Lying on right flank: roll 90 deg right | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.22]) | |
| q_base = np.array([0.7071, 0.7071, 0.0, 0.0]) | |
| elif pose_name == "kneeling_crumpled": | |
| # Crumpled on knees, torso pitched forward | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.48]) | |
| q_base = np.array([0.9238, 0.0, 0.3826, 0.0]) | |
| self.data.qpos[self.r_knee_qpos] = 1.7 | |
| self.data.qpos[self.l_knee_qpos] = 1.7 | |
| self.data.qpos[self.r_hip_qpos] = -1.0 | |
| self.data.qpos[self.l_hip_qpos] = -1.0 | |
| elif pose_name == "seated_slumped": | |
| # Slumped seated on buttocks, legs forward | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, 0.35]) | |
| q_base = np.array([0.9238, 0.0, -0.3826, 0.0]) | |
| self.data.qpos[self.r_hip_qpos] = -1.4 | |
| self.data.qpos[self.l_hip_qpos] = -1.4 | |
| self.data.qpos[self.r_knee_qpos] = 0.2 | |
| self.data.qpos[self.l_knee_qpos] = 0.2 | |
| else: | |
| # Random ragdoll drop: drop from 0.45m with completely randomized 3D orientation | |
| self.data.qpos[0:3] = np.array([spawn_x, spawn_y, float(np.random.uniform(0.40, 0.60))]) | |
| rand_quat = np.random.uniform(-1.0, 1.0, size=4) | |
| q_base = rand_quat / np.linalg.norm(rand_quat) | |
| # Apply randomized 360Β° yaw rotation to base pose quaternion | |
| yaw = float(np.random.uniform(-np.pi, np.pi)) | |
| q_yaw = np.array([np.cos(yaw / 2.0), 0.0, 0.0, np.sin(yaw / 2.0)], dtype=np.float64) | |
| # Quaternion multiplication: q_yaw * q_base | |
| w1, x1, y1, z1 = q_yaw | |
| w2, x2, y2, z2 = q_base | |
| q_rot = np.array([ | |
| w1*w2 - x1*x2 - y1*y2 - z1*z2, | |
| w1*x2 + x1*w2 + y1*z2 - z1*y2, | |
| w1*y2 - x1*z2 + y1*w2 + z1*x2, | |
| w1*z2 + x1*y2 - y1*x2 + z1*w2, | |
| ], dtype=np.float64) | |
| self.data.qpos[3:7] = q_rot / np.linalg.norm(q_rot) | |
| # Stochastic joint perturbation across all 21 joint degrees of freedom | |
| self.data.qpos[7:] += np.random.uniform(-0.20, 0.20, size=self.n_actuators) | |
| # Settle contact forces smoothly (12 passive sub-steps with zero actuation) | |
| mujoco.mj_forward(self.model, self.data) | |
| for _ in range(12): | |
| self.data.ctrl[:] = 0.0 | |
| mujoco.mj_step(self.model, self.data) | |
| self.data.qvel[:] = 0.0 | |
| mujoco.mj_forward(self.model, self.data) | |
| # Sample a randomized standing destination goal beacon (0.6m - 1.8m away at random heading) | |
| stand_dist = float(np.random.uniform(0.6, 1.8)) | |
| stand_heading = float(np.random.uniform(-np.pi, np.pi)) | |
| cur_torso = self.data.xpos[self.torso_body_id] | |
| gx = float(cur_torso[0] + stand_dist * np.cos(stand_heading)) | |
| gy = float(cur_torso[1] + stand_dist * np.sin(stand_heading)) | |
| gz = 1.168 # Grounded standing torso height equilibrium | |
| self.getup_goal_pos = np.array([gx, gy, gz], dtype=np.float32) | |
| self.goal_pos = self.getup_goal_pos.copy() | |
| def _sample_new_goal(self, is_initial: bool = False): | |
| """Samples a new random navigation target within the 50m x 50m flat arena.""" | |
| torso_pos = self.data.xpos[self.torso_body_id] | |
| if is_initial: | |
| # Initial target: distance 5.0m - 7.5m in forward cone (-60 deg to +60 deg) | |
| dist = float(np.random.uniform(5.0, 7.5)) | |
| angle = float(np.random.uniform(-np.pi / 3, np.pi / 3)) | |
| self.goal_transition_steps = 0 # Spawn directly facing target: immediate approach required | |
| else: | |
| # Subsequent target: distance 5.5m - 9.0m in full 360 deg omnidirectional bearing | |
| dist = float(np.random.uniform(5.5, 9.0)) | |
| angle = float(np.random.uniform(-np.pi, np.pi)) | |
| self.goal_transition_steps = self.transition_leeway_window # ~1.85s direction change leeway | |
| gx = float(np.clip(torso_pos[0] + dist * np.cos(angle), -22.0, 22.0)) | |
| gy = float(np.clip(torso_pos[1] + dist * np.sin(angle), -22.0, 22.0)) | |
| # Ensure newly sampled target is at least (arrival_radius + 1.5m = 3.5m) away from current torso | |
| for _ in range(20): | |
| if np.linalg.norm(np.array([gx - torso_pos[0], gy - torso_pos[1]])) >= (self.goal_arrival_radius + 1.5): | |
| break | |
| center_angle = float(np.arctan2(-torso_pos[1], -torso_pos[0])) | |
| angle = float(center_angle + np.random.uniform(-np.pi / 4, np.pi / 4)) | |
| dist = float(np.random.uniform(5.5, 8.5)) | |
| gx = float(np.clip(torso_pos[0] + dist * np.cos(angle), -22.0, 22.0)) | |
| gy = float(np.clip(torso_pos[1] + dist * np.sin(angle), -22.0, 22.0)) | |
| gz = 1.25 # Torso/chest level | |
| self.goal_pos = np.array([gx, gy, gz], dtype=np.float32) | |
| self.min_dist_to_goal = float(np.linalg.norm(self.goal_pos - torso_pos)) | |
| self.prev_dist_to_goal = float(self.min_dist_to_goal) | |
| self.steps_since_progress = 0 | |
| self.steps_not_approaching = 0 | |
| self.negative_vx_steps = 0 | |
| def observation_dim(self) -> int: | |
| """ | |
| Total observation vector length: | |
| - Torso height z: 1 | |
| - Torso orientation (forward & up vectors): 6 | |
| - Torso linear velocity (local/global): 3 | |
| - Torso angular velocity: 3 | |
| - 21 joint angles: 21 | |
| - 21 joint velocities: 21 | |
| - 2 foot contact forces: 2 | |
| - 512 solid-state LiDAR range rays (16x32 grid): 512 | |
| - 3 ego-centric relative waypoint vector (Ξx, Ξy, Ξz local): 3 | |
| - 3 metabolic stats (stamina, health, hunger): 3 | |
| Total: 1 + 6 + 3 + 3 + 21 + 21 + 2 + 512 + 3 + 3 = 575 | |
| """ | |
| return 575 | |
| def action_dim(self) -> int: | |
| return self.n_actuators # 21 | |
| def reset(self, seed: Optional[int] = None, spawn_x: float = 0.0, initial_vx: float = 0.0) -> np.ndarray: | |
| if seed is not None: | |
| np.random.seed(seed) | |
| mujoco.mj_resetData(self.model, self.data) | |
| # Initial root pose: at X=spawn_x, Y=0, Z=1.33 (athletic bent-knee stance) | |
| self.data.qpos[0] = spawn_x # X | |
| self.data.qpos[1] = 0.0 # Y | |
| self.data.qpos[2] = 1.33 # Z | |
| self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0]) # Quaternion (unit) | |
| # Staggered lead leg initialization: alternates 50% left lead, 50% right lead | |
| # This breaks single-leg asymmetric gait traps and forces both thighs to cycle | |
| lead_leg = np.random.choice(["right", "left"]) | |
| if lead_leg == "right": | |
| self.data.qpos[self.r_hip_qpos] = -0.35 # Right thigh poised forward | |
| self.data.qpos[self.r_knee_qpos] = 0.45 | |
| self.data.qpos[self.r_ankle_qpos] = -0.15 | |
| self.data.qpos[self.l_hip_qpos] = 0.05 # Left leg supporting in stance | |
| self.data.qpos[self.l_knee_qpos] = 0.25 | |
| self.data.qpos[self.l_ankle_qpos] = -0.05 | |
| else: | |
| self.data.qpos[self.l_hip_qpos] = -0.35 # Left thigh poised forward | |
| self.data.qpos[self.l_knee_qpos] = 0.45 | |
| self.data.qpos[self.l_ankle_qpos] = -0.15 | |
| self.data.qpos[self.r_hip_qpos] = 0.05 # Right leg supporting in stance | |
| self.data.qpos[self.r_knee_qpos] = 0.25 | |
| self.data.qpos[self.r_ankle_qpos] = -0.05 | |
| # Add slight stochastic noise to joint angles for exploration diversity | |
| joint_noise = np.random.uniform(-0.03, 0.03, size=self.n_actuators) | |
| self.data.qpos[7:] += joint_noise | |
| # Zero velocities with tiny noise (or inject forward running momentum if pioneer spawn) | |
| self.data.qvel[:] = np.random.uniform(-0.02, 0.02, size=self.n_qvel) | |
| if initial_vx > 0.0: | |
| self.data.qvel[0] = initial_vx + np.random.uniform(-0.1, 0.1) | |
| # Reset internal stats | |
| self.stamina = 1.0 | |
| self.health = 1.0 | |
| self.hunger = 0.0 | |
| self.milestones_reached = set() | |
| self.waypoints_reached = set() | |
| self.current_wp_idx = 0 | |
| if self.stream_mode == "stand": | |
| # Stream 1: Quiet Standing Balance (Canonical Reference Equilibrium Stance) | |
| self.data.qpos[0] = 0.0 | |
| self.data.qpos[1] = 0.0 | |
| self.data.qpos[2] = 1.168 | |
| self.data.qpos[3:7] = np.array([1.0, 0.0, 0.0, 0.0]) | |
| self.data.qvel[:] = 0.0 | |
| self.data.qpos[7:7 + self.n_actuators] = STAND_REF_QPOS.copy() | |
| # Add slight stochastic exploration noise (+/- 0.015 rad ~ 0.8 deg) so agent learns to balance | |
| self.data.qpos[7:7 + self.n_actuators] += np.random.uniform(-0.015, 0.015, size=self.n_actuators) | |
| # Settle foot ground contacts smoothly (10 passive sub-steps with zero actuation) | |
| mujoco.mj_forward(self.model, self.data) | |
| for _ in range(10): | |
| self.data.ctrl[:] = 0.0 | |
| mujoco.mj_step(self.model, self.data) | |
| self.data.qvel[:] = 0.0 | |
| mujoco.mj_forward(self.model, self.data) | |
| self.goal_pos = np.array([0.0, 0.0, 1.168], dtype=np.float32) | |
| self.stand_still_steps = 0 | |
| self.stand_drift_steps = 0 | |
| self.stand_one_leg_steps = 0 | |
| self.goals_completed = 0 | |
| elif self.stream_mode == "getup": | |
| # Stream 2: Fall Recovery (Randomized 360Β° poses & standing destination goal) | |
| self._apply_fall_state() | |
| self.prev_z = float(self.data.xpos[self.torso_body_id][2]) | |
| self.prev_head_z = float(self.data.xpos[self.head_body_id][2]) | |
| self.consecutive_upright_steps = 0 | |
| self.getup_stagnant_steps = 0 | |
| self.max_elevation_achieved = self.prev_z | |
| self.max_head_elevation = self.prev_head_z | |
| self.getup_success_count = 0 | |
| self.goals_completed = 0 | |
| elif self.stream_mode == "reach": | |
| # Stream 2: Bilateral Hand Reaching (Bolted Torso with neutral resting arm configuration) | |
| self._lock_torso_for_reach() | |
| # Initialize both arms down at sides (natural resting posture at hip) | |
| self.data.qpos[self.arm_qpos_indices[0]] = 0.65 + float(np.random.uniform(-0.03, 0.03)) # right_shoulder_x (down at side) | |
| self.data.qpos[self.arm_qpos_indices[1]] = float(np.random.uniform(-0.03, 0.03)) # right_shoulder_y | |
| self.data.qpos[self.arm_qpos_indices[2]] = float(np.random.uniform(-0.03, 0.03)) # right_elbow | |
| self.data.qpos[self.arm_qpos_indices[3]] = -0.65 + float(np.random.uniform(-0.03, 0.03)) # left_shoulder_x (down at side) | |
| self.data.qpos[self.arm_qpos_indices[4]] = float(np.random.uniform(-0.03, 0.03)) # left_shoulder_y | |
| self.data.qpos[self.arm_qpos_indices[5]] = float(np.random.uniform(-0.03, 0.03)) # left_elbow | |
| self.data.qvel[self.arm_dof_indices] = 0.0 | |
| mujoco.mj_forward(self.model, self.data) | |
| self._sample_reach_target() | |
| is_right_target = (self.active_reach_hand == "right") | |
| active_h = self.data.geom_xpos[self.r_hand_geom_id] if is_right_target else self.data.geom_xpos[self.l_hand_geom_id] | |
| self.prev_reach_dist = float(np.linalg.norm(active_h - self.reach_target_pos)) | |
| self.min_reach_dist = self.prev_reach_dist | |
| self.reach_touches = 0 | |
| self.steps_on_reach_target = 0 | |
| self.steps_receding_reach = 0 | |
| self.goals_completed = 0 | |
| elif self.arena_mode: | |
| self.goals_completed = 0 | |
| self._sample_new_goal(is_initial=True) | |
| else: | |
| # Fast-forward waypoint index if spawning downfield | |
| for idx, (wp_pos, _, _) in enumerate(COURSE_WAYPOINTS): | |
| if wp_pos[0] > spawn_x + 0.3: | |
| self.current_wp_idx = idx | |
| break | |
| self.spawn_x = float(spawn_x) | |
| self.furthest_x = float(spawn_x) | |
| self.current_step = 0 | |
| self.steps_since_progress = 0 | |
| self.last_progress_x = float(spawn_x) | |
| self.negative_vx_steps = 0 | |
| self.steps_not_approaching = 0 | |
| self.r_foot_contact_steps = 0 | |
| self.l_foot_contact_steps = 0 | |
| mujoco.mj_forward(self.model, self.data) | |
| return self._get_obs() | |
| def _get_obs(self) -> np.ndarray: | |
| # 1. Torso state | |
| torso_xpos = self.data.xpos[self.torso_body_id] # (3,) | |
| torso_xmat = self.data.xmat[self.torso_body_id].reshape(3, 3) # (3, 3) | |
| forward_vec = torso_xmat[:, 0] # Local X axis in world frame | |
| up_vec = torso_xmat[:, 2] # Local Z axis in world frame | |
| torso_cvel = self.data.cvel[self.torso_body_id] # 6D: [ang_vel(3), lin_vel(3)] | |
| ang_vel = torso_cvel[:3] | |
| lin_vel = torso_cvel[3:] | |
| # 2. Joint states (21 angles, 21 velocities) | |
| joint_qpos = self.data.qpos[7:7 + self.n_actuators] | |
| joint_qvel = self.data.qvel[6:6 + self.n_actuators] | |
| # 3. Touch sensors & Task-Specific Contact Indicators | |
| touch_forces = np.zeros(2, dtype=np.float32) | |
| if self.stream_mode == "reach": | |
| # For isolated Reach, legs are bolted stationary. Repurpose touch_forces as explicit Active Hand Indicator: | |
| # [1.0, 0.0] -> Right Hand Active | [0.0, 1.0] -> Left Hand Active | |
| if self.active_reach_hand == "right": | |
| touch_forces[0] = 1.0 | |
| touch_forces[1] = 0.0 | |
| else: | |
| touch_forces[0] = 0.0 | |
| touch_forces[1] = 1.0 | |
| else: | |
| if self.model.nsensor >= 2: | |
| touch_forces[0] = np.clip(self.data.sensordata[0] / 100.0, 0.0, 5.0) # Right foot | |
| touch_forces[1] = np.clip(self.data.sensordata[1] / 100.0, 0.0, 5.0) # Left foot | |
| # 4. True Solid-State Angular LiDAR (512 rays from visor/eyes) | |
| lidar_obs = self._compute_lidar_vision() | |
| # 5. Dynamic Goal / Progressive Waypoint (Ego-centric local relative vector) | |
| if self.stream_mode == "stand": | |
| rel_world = self.goal_pos - torso_xpos | |
| rel_local = torso_xmat.T @ rel_world | |
| waypoint_obs = np.array([ | |
| np.clip(rel_local[0] / 10.0, -1.0, 1.0), | |
| np.clip(rel_local[1] / 5.0, -1.0, 1.0), | |
| np.clip(rel_local[2] / 2.0, -1.0, 1.0), | |
| ], dtype=np.float32) | |
| elif self.stream_mode == "getup": | |
| rel_world = self.getup_goal_pos - torso_xpos | |
| rel_local = torso_xmat.T @ rel_world | |
| waypoint_obs = np.array([ | |
| np.clip(rel_local[0] / 5.0, -1.0, 1.0), | |
| np.clip(rel_local[1] / 5.0, -1.0, 1.0), | |
| np.clip(rel_local[2] / 2.0, -1.0, 1.0), | |
| ], dtype=np.float32) | |
| elif self.stream_mode == "reach": | |
| # Direct closed-loop motor servoing: vector from ACTIVE HAND directly to the reach target orb! | |
| active_h = self.data.geom_xpos[self.r_hand_geom_id] if (self.active_reach_hand == "right") else self.data.geom_xpos[self.l_hand_geom_id] | |
| rel_hand_world = self.reach_target_pos - active_h | |
| rel_hand_local = torso_xmat.T @ rel_hand_world | |
| waypoint_obs = np.array([ | |
| np.clip(rel_hand_local[0] / 0.6, -1.0, 1.0), | |
| np.clip(rel_hand_local[1] / 0.6, -1.0, 1.0), | |
| np.clip(rel_hand_local[2] / 0.6, -1.0, 1.0), | |
| ], dtype=np.float32) | |
| elif self.arena_mode: | |
| target_pos = self.goal_pos | |
| rel_world = target_pos - torso_xpos | |
| rel_local = torso_xmat.T @ rel_world | |
| waypoint_obs = np.array([ | |
| np.clip(rel_local[0] / 10.0, -1.0, 1.0), | |
| np.clip(rel_local[1] / 5.0, -1.0, 1.0), | |
| np.clip(rel_local[2] / 2.0, -1.0, 1.0), | |
| ], dtype=np.float32) | |
| else: | |
| target_pos = COURSE_WAYPOINTS[self.current_wp_idx][0] | |
| rel_world = target_pos - torso_xpos | |
| rel_local = torso_xmat.T @ rel_world | |
| waypoint_obs = np.array([ | |
| np.clip(rel_local[0] / 10.0, -1.0, 1.0), | |
| np.clip(rel_local[1] / 5.0, -1.0, 1.0), | |
| np.clip(rel_local[2] / 2.0, -1.0, 1.0), | |
| ], dtype=np.float32) | |
| # 6. Metabolic state (stamina, health, hunger/task-channel) | |
| if self.stream_mode == "reach": | |
| hand_indicator = 1.0 if (self.active_reach_hand == "right") else -1.0 | |
| metabolic_obs = np.array([self.stamina, self.health, hand_indicator], dtype=np.float32) | |
| else: | |
| metabolic_obs = np.array([self.stamina, self.health, self.hunger], dtype=np.float32) | |
| obs = np.concatenate([ | |
| np.array([torso_xpos[2]], dtype=np.float32), # Torso Z (1) | |
| forward_vec.astype(np.float32), # 3 | |
| up_vec.astype(np.float32), # 3 | |
| lin_vel.astype(np.float32), # 3 | |
| ang_vel.astype(np.float32), # 3 | |
| joint_qpos.astype(np.float32), # 21 | |
| joint_qvel.astype(np.float32), # 21 | |
| touch_forces, # 2 | |
| lidar_obs, # 512 | |
| waypoint_obs, # 3 | |
| metabolic_obs, # 3 | |
| ]) | |
| return np.nan_to_num(obs, nan=0.0, posinf=50.0, neginf=-50.0) | |
| def _compute_lidar_vision(self) -> np.ndarray: | |
| """ | |
| True Solid-State Angular LiDAR scan from the robot's visor/eyes. | |
| Casts 128 rays (8 vertical elevation x 16 horizontal azimuth) using MuJoCo's native C raycaster. | |
| Returns normalized range vector (128,) in [0.0, 1.0]. | |
| """ | |
| head_pos = self.data.xpos[self.head_body_id] | |
| head_mat = self.data.xmat[self.head_body_id].reshape(3, 3) | |
| # Ray origin: 0.15m forward from head center through visor | |
| ray_origin = np.ascontiguousarray(head_pos + head_mat @ self._visor_offset, dtype=np.float64) | |
| # Transform all 128 precomputed local vectors to world coordinates | |
| # (128, 3) @ (3, 3).T -> (128, 3) | |
| world_vectors = self.lidar_local_vectors @ head_mat.T | |
| normalized_ranges = np.zeros(self.num_lidar_rays, dtype=np.float32) | |
| raw_grid = np.zeros((self.num_lidar_v, self.num_lidar_h), dtype=np.float32) | |
| ray_idx = 0 | |
| for row in range(self.num_lidar_v): | |
| for col in range(self.num_lidar_h): | |
| vec = np.ascontiguousarray(world_vectors[ray_idx], dtype=np.float64) | |
| dist = mujoco.mj_ray(self.model, self.data, ray_origin, vec, None, 1, -1, self._geomid_buf) | |
| if dist >= 0: | |
| gid = int(self._geomid_buf[0]) | |
| if gid in self.humanoid_geoms: | |
| actual_dist = self.lidar_max_range | |
| else: | |
| actual_dist = min(dist, self.lidar_max_range) | |
| else: | |
| actual_dist = self.lidar_max_range | |
| raw_grid[row, col] = actual_dist | |
| # Zero-centered range: -0.5 (impact / 0m) to +0.5 (clear / 10m), eliminates DC bias | |
| normalized_ranges[ray_idx] = (actual_dist / self.lidar_max_range) - 0.5 | |
| ray_idx += 1 | |
| self.current_lidar_grid = raw_grid | |
| return normalized_ranges | |
| def _compute_pseudo_vision(self, agent_x: float, agent_y: float, agent_z: float) -> np.ndarray: | |
| """Backwards compatibility fallback for 1D pseudo-vision.""" | |
| return self._compute_lidar_vision()[:16] | |
| def step(self, action: np.ndarray) -> Tuple[np.ndarray, float, bool, bool, Dict]: | |
| self.current_step += 1 | |
| # Clip action to [-1.0, 1.0] | |
| action = np.clip(action, -1.0, 1.0) | |
| self.data.ctrl[:] = action | |
| if self.stream_mode == "reach": | |
| # For isolated arm reaching, freeze motor commands to core and legs | |
| self.data.ctrl[0:15] = 0.0 | |
| # Substep physics | |
| for _ in range(self.frame_skip): | |
| mujoco.mj_step(self.model, self.data) | |
| if self.stream_mode == "reach": | |
| self._lock_torso_for_reach() | |
| # Extract state measurements | |
| torso_xpos = self.data.xpos[self.torso_body_id] | |
| x, y, z = torso_xpos[0], torso_xpos[1], torso_xpos[2] | |
| torso_cvel = self.data.cvel[self.torso_body_id] | |
| vx = torso_cvel[3] # Forward linear velocity | |
| vy = torso_cvel[4] | |
| torso_xmat = self.data.xmat[self.torso_body_id].reshape(3, 3) | |
| up_z = torso_xmat[2, 2] # Cosine of torso vertical tilt | |
| # Update metabolic stats | |
| torque_cost = np.sum(np.square(action)) | |
| self.stamina = np.clip(self.stamina - (torque_cost * 0.0003) + 0.0005, 0.0, 1.0) | |
| self.hunger = np.clip(self.hunger + 0.0001, 0.0, 1.0) | |
| # Multi-limb ground contact detection for floor righting and locomotion | |
| contact_r = False | |
| contact_l = False | |
| contact_hand = False | |
| contact_knee = False | |
| for c_idx in range(self.data.ncon): | |
| c = self.data.contact[c_idx] | |
| g1, g2 = c.geom1, c.geom2 | |
| if (g1 == self.r_foot_geom_id and g2 in self.world_obstacle_geoms) or (g2 == self.r_foot_geom_id and g1 in self.world_obstacle_geoms): | |
| contact_r = True | |
| if (g1 == self.l_foot_geom_id and g2 in self.world_obstacle_geoms) or (g2 == self.l_foot_geom_id and g1 in self.world_obstacle_geoms): | |
| contact_l = True | |
| if (g1 in self.support_hand_geoms and g2 in self.world_obstacle_geoms) or (g2 in self.support_hand_geoms and g1 in self.world_obstacle_geoms): | |
| contact_hand = True | |
| if (g1 in self.support_knee_geoms and g2 in self.world_obstacle_geoms) or (g2 in self.support_knee_geoms and g1 in self.world_obstacle_geoms): | |
| contact_knee = True | |
| touch_r = contact_r or (bool(self.data.sensordata[0] > 5.0) if self.model.nsensor >= 1 else False) | |
| touch_l = contact_l or (bool(self.data.sensordata[1] > 5.0) if self.model.nsensor >= 2 else False) | |
| if touch_r: | |
| self.r_foot_contact_steps += 1 | |
| if touch_l: | |
| self.l_foot_contact_steps += 1 | |
| upright_scale = float(np.clip((z - 0.45) / 0.40, 0.0, 1.0)) # 0 at z<=0.45m, 1.0 at z>=0.85m | |
| in_leeway = False | |
| torso_speed = float(np.linalg.norm(torso_cvel[3:])) | |
| # ββ STREAM 1: QUIET STANDING STILLNESS (REFERENCE POSE SIMILARITY) ββββββββ | |
| if self.stream_mode == "stand": | |
| head_z = float(self.data.xpos[self.head_body_id][2]) | |
| dist_from_origin = float(np.linalg.norm([x, y])) | |
| # 1. Joint Configuration Similarity: | |
| # Gaussian kernel comparing 21 actuated joints against canonical STAND_REF_QPOS | |
| current_qpos = self.data.qpos[7:7 + self.n_actuators] | |
| diff_q = current_qpos - STAND_REF_QPOS | |
| weighted_q_mse = float(np.sum(STAND_JOINT_WEIGHTS * (diff_q ** 2)) / np.sum(STAND_JOINT_WEIGHTS)) | |
| r_pose = float(np.exp(-3.0 * weighted_q_mse)) | |
| # 2. Torso Center-of-Mass Height Similarity (centered at z* = 1.168m): | |
| r_height = float(np.exp(-30.0 * ((z - 1.168) ** 2))) | |
| # 3. Torso Upright Orientation Similarity: | |
| r_upright = float(np.exp(-12.0 * (max(0.0, 1.0 - up_z) ** 2))) | |
| # 4. Multi-Rigid-Body Stillness Similarity (root linear, root angular, joint vel): | |
| lin_vel_sq = float(np.sum(torso_cvel[3:] ** 2)) | |
| ang_vel_sq = float(np.sum(torso_cvel[:3] ** 2)) | |
| joint_vel_sq = float(np.mean(self.data.qvel[6:6 + self.n_actuators] ** 2)) | |
| r_stillness = float(np.exp(-4.0 * (lin_vel_sq + 0.5 * ang_vel_sq + 0.1 * joint_vel_sq))) | |
| # 5. Foot Ground Contact Multiplier: | |
| both_feet_down = bool(touch_r and touch_l) | |
| one_foot_down = bool(touch_r or touch_l) | |
| if both_feet_down: | |
| m_feet = 1.0 | |
| elif one_foot_down: | |
| m_feet = 0.45 | |
| else: | |
| m_feet = 0.05 | |
| # 6. Action Control / Effort Penalty: | |
| r_ctrl = -0.002 * float(np.sum(action ** 2)) | |
| # Bounded Gaussian Similarity Reward (Peak = 14.0/step at quiet standing equilibrium) | |
| total_reward = m_feet * (4.0 * r_pose + 3.0 * r_height + 3.0 * r_upright + 4.0 * r_stillness) + r_ctrl | |
| # Track stillness success criteria: | |
| is_stand_still = bool( | |
| r_pose > 0.85 and | |
| r_height > 0.88 and | |
| r_upright > 0.90 and | |
| r_stillness > 0.80 and | |
| both_feet_down | |
| ) | |
| if is_stand_still: | |
| self.stand_still_steps += 1 | |
| else: | |
| self.stand_still_steps = max(0, self.stand_still_steps - 1) | |
| # Check collision with environmental obstacles/ground using body geoms (head, chest, torso, visor) | |
| is_obstacle_or_ground_impact = False | |
| for c_idx in range(self.data.ncon): | |
| c = self.data.contact[c_idx] | |
| if (c.geom1 in self.body_impact_geoms and c.geom2 in self.world_obstacle_geoms) or \ | |
| (c.geom2 in self.body_impact_geoms and c.geom1 in self.world_obstacle_geoms): | |
| is_obstacle_or_ground_impact = True | |
| break | |
| terminated = False | |
| # Clean early termination only when genuinely fallen or severely displaced: | |
| if z < 0.85 or up_z < 0.55 or is_obstacle_or_ground_impact: | |
| terminated = True | |
| total_reward -= 15.0 | |
| elif dist_from_origin > 1.20: | |
| terminated = True | |
| total_reward -= 10.0 | |
| # Mastery time limit: 384 steps (~9.6s) | |
| truncated = (self.current_step >= 384) | |
| if truncated and (not terminated) and z > 1.10 and both_feet_down: | |
| total_reward += 50.0 # Mastery bonus for surviving 384 steps upright on both feet | |
| # Detailed metrics for logging & dashboard | |
| torso_roll = abs(float(torso_xmat[2, 1])) | |
| torso_pitch = abs(float(torso_xmat[2, 0])) | |
| spine_lateral = abs(float(self.data.qpos[self.abdomen_x_qpos])) | |
| spine_sagittal = abs(float(self.data.qpos[self.abdomen_y_qpos])) | |
| info = { | |
| "x_pos": float(x), "y_pos": float(y), "z_pos": float(z), | |
| "vx": float(vx), "torso_speed": float(torso_speed), | |
| "furthest_x": float(self.furthest_x), "milestones": 0, | |
| "waypoint_idx": 0, "waypoint_name": "Quiet Stand Stillness", | |
| "dist_to_wp": float(dist_from_origin), "waypoints_completed": int(self.stand_still_steps > 150), | |
| "goals_completed": int(self.stand_still_steps > 150), | |
| "dist_to_goal": float(dist_from_origin), | |
| "goal_arrival_radius": 1.20, | |
| "goal_pos": [0.0, 0.0, 1.168], | |
| "target_local_pos": [0.0, 0.0, 0.0], | |
| "gaze_alignment": 1.0, | |
| "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger), | |
| "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(), | |
| "in_leeway": False, "leeway_remaining": 0, | |
| "stream_mode": "stand", "active_reach_hand": "none", "reach_touches": 0, | |
| "getup_success": False, | |
| "both_feet_down": bool(both_feet_down), | |
| "one_foot_down": bool(one_foot_down), | |
| "stand_still_score": float(is_stand_still), | |
| "torso_roll": float(torso_roll), | |
| "torso_pitch": float(torso_pitch), | |
| "spine_lateral": float(spine_lateral), | |
| "spine_sagittal": float(spine_sagittal), | |
| "r_pose": float(r_pose), | |
| "r_height": float(r_height), | |
| "r_upright": float(r_upright), | |
| "r_stillness": float(r_stillness), | |
| } | |
| return self._get_obs(), float(total_reward), terminated, truncated, info | |
| # ββ STREAM 2: FALL RECOVERY (GET-UP) ββββββββββββββββββββββββββββββββ | |
| elif self.stream_mode == "getup": | |
| head_z = float(self.data.xpos[self.head_body_id][2]) | |
| delta_head_z = head_z - self.prev_head_z | |
| self.prev_head_z = head_z | |
| r_foot_z = float(self.data.xpos[self.r_foot_id][2]) | |
| l_foot_z = float(self.data.xpos[self.l_foot_id][2]) | |
| max_foot_z = max(r_foot_z, l_foot_z) | |
| # 1. REWARD FOR HEAD & TORSO ELEVATION (Rises vertically, head leads above torso): | |
| reward_head_elevation = 16.0 * delta_head_z | |
| reward_torso_elevation = 12.0 * max(0.0, z - self.prev_z) | |
| reward_head_height = 5.0 * max(0.0, head_z - 0.40) | |
| reward_head_above_torso = 3.5 * max(0.0, head_z - z) | |
| reward_upright = 3.0 * max(0.0, up_z - 0.30) | |
| # 2. MULTI-PHASE GROUND CONTACT SUPPORT (Hands, Knees, Feet): | |
| both_feet_down = touch_r and touch_l | |
| one_foot_down = touch_r or touch_l | |
| # Floor bracing support (head_z < 1.15m): reward hands pushing floor & knees tucked | |
| reward_hand_brace = 4.0 if (contact_hand and head_z < 1.15) else 0.0 | |
| reward_knee_brace = 3.0 if (contact_knee and head_z < 1.15) else 0.0 | |
| # Standing stage (head_z >= 1.05m): reward planting feet | |
| if both_feet_down: | |
| reward_feet_ground = 6.0 | |
| elif one_foot_down: | |
| reward_feet_ground = 2.0 | |
| elif head_z >= 1.20: | |
| reward_feet_ground = -1.0 # Only penalize missing foot contact once upright | |
| else: | |
| reward_feet_ground = 0.0 # Zero penalty while floor-bound rolling or pushing up! | |
| # Floor proximity for feet during ground phase | |
| reward_feet_down = 2.0 * max(0.0, 0.30 - max_foot_z) if head_z < 1.15 else 0.0 | |
| # 3. MITIGATE LEG AIR-FLAILING: | |
| # Only penalize feet higher than head IF head is actually on the ground and feet are violently kicked up | |
| feet_above_head = max(0.0, max_foot_z - max(0.40, head_z)) | |
| penalty_shooting_legs = -4.0 * feet_above_head | |
| # Air-flail penalty only when feet are high in air with zero contact | |
| penalty_air_flail = -2.0 * max(0.0, max_foot_z - 0.50) if (head_z < 1.0 and not (contact_hand or contact_knee)) else 0.0 | |
| # 4. Energy cost & Arm Windmilling Suppression | |
| arm_flail_penalty = -0.001 * float(np.sum(np.square(action[15:21]))) | |
| reward_ctrl = -0.001 * torque_cost + arm_flail_penalty | |
| dist_to_stand_goal = float(np.linalg.norm(self.getup_goal_pos[:2] - np.array([x, y]))) | |
| reward_goal_heading = 2.0 * max(0.0, 1.0 - dist_to_stand_goal / 2.0) if head_z > 1.30 else 0.0 | |
| total_reward = ( | |
| reward_head_elevation + | |
| reward_torso_elevation + | |
| reward_head_height + | |
| reward_head_above_torso + | |
| reward_upright + | |
| reward_hand_brace + | |
| reward_knee_brace + | |
| reward_feet_ground + | |
| reward_feet_down + | |
| penalty_shooting_legs + | |
| penalty_air_flail + | |
| reward_ctrl + | |
| reward_goal_heading | |
| ) | |
| self.prev_z = z | |
| # Track peak head elevation for stagnation monitoring | |
| if head_z > self.max_head_elevation: | |
| self.max_head_elevation = head_z | |
| self.getup_stagnant_steps = 0 | |
| else: | |
| self.getup_stagnant_steps += 1 | |
| # Upright standing with feet planted verification: | |
| # Requires head up (> 1.35m), torso upright (z > 1.05m, up_z > 0.70), and foot contact | |
| is_upright = (head_z > 1.35) and (z > 1.05) and (up_z > 0.70) and (one_foot_down or (head_z > 1.45 and z > 1.10)) | |
| if is_upright: | |
| self.consecutive_upright_steps += 1 | |
| total_reward += 3.0 | |
| if both_feet_down: | |
| total_reward += 3.0 # Extra jackpot bonus when BOTH feet are planted! | |
| else: | |
| self.consecutive_upright_steps = 0 | |
| # Lenient Ground Collapse Stagnation & Rise Stall Deaths: | |
| # Tolerates 140 steps (~3.5s) struggling on ground without head elevation | |
| # Tolerates 180 steps (~4.5s) coordinating knee-tuck and feet planting to reach upright stance | |
| is_ground_stuck = (head_z < 0.50 and self.getup_stagnant_steps > 140) | |
| is_stall_stuck = (head_z >= 0.50 and self.consecutive_upright_steps == 0 and self.getup_stagnant_steps > 180) | |
| terminated = False | |
| if self.consecutive_upright_steps >= 12: | |
| total_reward += 100.0 # Upright Recovery Jackpot! | |
| self.getup_success_count += 1 | |
| terminated = True | |
| elif is_ground_stuck or is_stall_stuck: | |
| terminated = True | |
| total_reward -= 10.0 # Timeout stagnation death | |
| truncated = (self.current_step >= 280) | |
| target_pos = self.getup_goal_pos | |
| info = { | |
| "x_pos": float(x), "y_pos": float(y), "z_pos": float(z), | |
| "vx": float(vx), "torso_speed": float(torso_speed), | |
| "furthest_x": float(self.furthest_x), "milestones": 0, | |
| "waypoint_idx": 0, "waypoint_name": f"Get-Up ({self.fall_state_type})", | |
| "dist_to_wp": float(max(0.0, 1.55 - head_z)), | |
| "waypoints_completed": int(self.consecutive_upright_steps >= 12), | |
| "goals_completed": int(self.consecutive_upright_steps >= 12), | |
| "dist_to_goal": float(dist_to_stand_goal), | |
| "goal_arrival_radius": 0.50, | |
| "goal_pos": self.getup_goal_pos.tolist(), | |
| "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(), | |
| "gaze_alignment": float(max(0.0, up_z)), | |
| "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger), | |
| "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(), | |
| "in_leeway": False, "leeway_remaining": 0, | |
| "stream_mode": "getup", "active_reach_hand": "none", "reach_touches": 0, | |
| "getup_success": bool(self.consecutive_upright_steps >= 12), | |
| "head_z": float(head_z), | |
| "both_feet_down": bool(both_feet_down), | |
| } | |
| return self._get_obs(), float(total_reward), terminated, truncated, info | |
| # ββ STREAM 2: BILATERAL HAND REACHING (BOLTED BODY) ββββββββββββββββ | |
| elif self.stream_mode == "reach": | |
| # Torso, core, and legs are bolted in neutral athletic upright stance. | |
| # Control is isolated strictly to the 6 arm actuators reaching for 3D orbs. | |
| r_h = self.data.geom_xpos[self.r_hand_geom_id] | |
| l_h = self.data.geom_xpos[self.l_hand_geom_id] | |
| dist_r = float(np.linalg.norm(r_h - self.reach_target_pos)) | |
| dist_l = float(np.linalg.norm(l_h - self.reach_target_pos)) | |
| # The designated active hand is the hand closer to the target ball | |
| is_right_target = (self.active_reach_hand == "right") | |
| if is_right_target: | |
| active_h = r_h | |
| wrong_h = l_h | |
| dist_to_target = dist_r | |
| dist_inactive = dist_l | |
| active_body_id = self.r_arm_body_id | |
| wrong_body_id = self.l_arm_body_id | |
| active_act = action[15:18] | |
| wrong_act = action[18:21] | |
| active_qvel = self.data.qvel[self.arm_dof_indices[0:3]] | |
| wrong_qvel = self.data.qvel[self.arm_dof_indices[3:6]] | |
| wrong_qpos = self.data.qpos[self.arm_qpos_indices[3:6]] | |
| wrong_rest_qpos = np.array([-0.65, 0.0, 0.0], dtype=np.float32) | |
| else: | |
| active_h = l_h | |
| wrong_h = r_h | |
| dist_to_target = dist_l | |
| dist_inactive = dist_r | |
| active_body_id = self.l_arm_body_id | |
| wrong_body_id = self.r_arm_body_id | |
| active_act = action[18:21] | |
| wrong_act = action[15:18] | |
| active_qvel = self.data.qvel[self.arm_dof_indices[3:6]] | |
| wrong_qvel = self.data.qvel[self.arm_dof_indices[0:3]] | |
| wrong_qpos = self.data.qpos[self.arm_qpos_indices[0:3]] | |
| wrong_rest_qpos = np.array([0.65, 0.0, 0.0], dtype=np.float32) | |
| # 1. Main reach distance progress and proximity reward | |
| dist_prog = self.prev_reach_dist - dist_to_target | |
| reward_reach_prog = 25.0 * dist_prog | |
| reward_proximity = 6.0 * np.exp(-4.0 * dist_to_target) | |
| # 2. MASSIVE SPEED EFFICIENCY REWARDS: | |
| # a) Direct closing velocity vector along the unit ray connecting active hand to target orb | |
| delta_active_target = self.reach_target_pos - active_h | |
| dist_hand_target = max(1e-4, float(np.linalg.norm(delta_active_target))) | |
| u_reach_target = delta_active_target / dist_hand_target | |
| active_hand_vel = self.data.cvel[active_body_id][3:6] | |
| v_closing = float(np.dot(active_hand_vel, u_reach_target)) | |
| # Speed efficiency bonus for thrusting active hand toward target | |
| reward_closing_speed = 4.0 * max(0.0, v_closing) | |
| # b) Mild latency penalty (encourages prompt reaching) | |
| penalty_latency = -0.02 * (float(self.steps_on_reach_target) / 40.0) | |
| # 3. Slight reward for moving the correct hand | |
| active_hand_speed = float(np.linalg.norm(active_hand_vel)) | |
| active_qvel_mag = float(np.sum(np.abs(active_qvel))) | |
| reward_correct_motion = 0.15 * min(1.0, active_hand_speed / 0.25) + 0.10 * np.tanh(active_qvel_mag) | |
| # 4. REGULARIZATION ON INACTIVE HAND (stay neutral and relaxed at side) | |
| wrong_hand_speed = float(np.linalg.norm(self.data.cvel[wrong_body_id][3:6])) | |
| penalty_wrong_act = -0.05 * float(np.sum(np.square(wrong_act))) | |
| penalty_wrong_qvel = -0.05 * float(np.sum(np.abs(wrong_qvel))) | |
| penalty_wrong_speed = -0.20 * min(1.0, wrong_hand_speed) | |
| penalty_wrong_raised = -0.50 * max(0.0, float(wrong_h[2]) - 1.28) | |
| penalty_wrong_posture = -0.20 * min(1.0, float(np.linalg.norm(wrong_qpos - wrong_rest_qpos))) | |
| penalty_wrong_lead = -0.50 * max(0.0, dist_to_target - dist_inactive) | |
| penalty_wrong_encroach = -0.50 * max(0.0, 0.35 - dist_inactive) | |
| penalty_wrong_touch = -2.0 if dist_inactive < 0.14 else 0.0 | |
| # General control penalty on active arm to encourage efficient articulation | |
| reward_ctrl = -0.001 * float(np.sum(np.square(active_act))) | |
| self.steps_on_reach_target += 1 | |
| if dist_to_target < self.min_reach_dist: | |
| self.min_reach_dist = dist_to_target | |
| self.steps_receding_reach = 0 | |
| elif dist_to_target > self.min_reach_dist + 0.25: | |
| self.steps_receding_reach += 1 | |
| reward_touch = 0.0 | |
| speed_efficiency_jackpot = 0.0 | |
| if dist_to_target < 0.14: | |
| # 1. Base touch award | |
| base_touch = 40.0 | |
| # 2. Speed efficiency jackpot: maximum when acquired in few steps | |
| speed_efficiency_jackpot = max(0.0, 110.0 - float(self.steps_on_reach_target)) * 0.40 | |
| # 3. Consecutive hit streak multiplier | |
| touch_streak_bonus = min(30.0, float(self.reach_touches) * 6.0) | |
| reward_touch = base_touch + speed_efficiency_jackpot + touch_streak_bonus | |
| self.reach_touches += 1 | |
| self._sample_reach_target() | |
| is_right_target = (self.active_reach_hand == "right") | |
| active_h = self.data.geom_xpos[self.r_hand_geom_id] if is_right_target else self.data.geom_xpos[self.l_hand_geom_id] | |
| dist_to_target = float(np.linalg.norm(active_h - self.reach_target_pos)) | |
| # Reset timers for next target! | |
| self.steps_on_reach_target = 0 | |
| self.steps_receding_reach = 0 | |
| self.min_reach_dist = dist_to_target | |
| self.prev_reach_dist = dist_to_target | |
| total_reward = ( | |
| reward_reach_prog + | |
| reward_proximity + | |
| reward_closing_speed + | |
| reward_correct_motion + | |
| reward_touch + | |
| penalty_latency + | |
| penalty_wrong_act + | |
| penalty_wrong_qvel + | |
| penalty_wrong_speed + | |
| penalty_wrong_raised + | |
| penalty_wrong_posture + | |
| penalty_wrong_lead + | |
| penalty_wrong_encroach + | |
| penalty_wrong_touch + | |
| reward_ctrl | |
| ) | |
| # Lenient Reaching Time Limits: | |
| # Tolerates 110 steps (~2.75s) per target orb, or 45 steps receding away | |
| is_reach_timeout = (self.steps_on_reach_target > 110) | |
| is_reach_receding = (self.steps_receding_reach > 45) | |
| terminated = False | |
| if is_reach_timeout or is_reach_receding: | |
| terminated = True | |
| total_reward -= 2.0 # Mild timeout penalty | |
| truncated = (self.current_step >= 384) | |
| target_pos = self.reach_target_pos | |
| head_xmat = self.data.xmat[self.head_body_id].reshape(3, 3) | |
| head_fwd = head_xmat[:, 0] | |
| delta_head_target = target_pos - self.data.xpos[self.head_body_id] | |
| cos_gaze = float(np.dot(head_fwd, delta_head_target / max(1e-3, np.linalg.norm(delta_head_target)))) | |
| info = { | |
| "x_pos": float(x), "y_pos": float(y), "z_pos": float(z), | |
| "vx": float(vx), "torso_speed": float(torso_speed), | |
| "furthest_x": float(self.furthest_x), "milestones": 0, | |
| "waypoint_idx": 0, "waypoint_name": f"Reach ({self.active_reach_hand.upper()})", | |
| "dist_to_wp": float(dist_to_target), "waypoints_completed": int(self.reach_touches), | |
| "goals_completed": int(self.reach_touches), | |
| "dist_to_goal": float(dist_to_target), | |
| "goal_arrival_radius": 0.14, | |
| "goal_pos": self.reach_target_pos.tolist(), | |
| "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(), | |
| "gaze_alignment": float(cos_gaze), | |
| "stamina": float(self.stamina), "health": float(self.health), "hunger": float(self.hunger), | |
| "step": self.current_step, "lidar_grid": self.current_lidar_grid.tolist(), | |
| "in_leeway": False, "leeway_remaining": 0, | |
| "stream_mode": "reach", "active_reach_hand": str(self.active_reach_hand), | |
| "reach_touches": int(self.reach_touches), "getup_success": False, "stand_still_score": 0.0, | |
| "wrong_hand_dist": float(dist_inactive), "wrong_hand_touch": bool(dist_inactive < 0.14), | |
| "wrong_hand_speed": float(wrong_hand_speed), | |
| "wrong_arm_action_penalty": float(penalty_wrong_act), | |
| "wrong_arm_raised_penalty": float(penalty_wrong_raised), | |
| "v_closing": float(v_closing), | |
| "reward_closing_speed": float(reward_closing_speed), | |
| "speed_efficiency_jackpot": float(speed_efficiency_jackpot), | |
| } | |
| return self._get_obs(), float(total_reward), terminated, truncated, info | |
| # Target Navigation Vector & Forward Propulsion | |
| if self.arena_mode: | |
| target_pos = self.goal_pos | |
| delta_target = target_pos - torso_xpos | |
| dist_to_target = float(np.linalg.norm(delta_target)) | |
| dist_xy = float(np.linalg.norm(delta_target[:2])) | |
| # Check dynamic goal arrival (entering wide arrival radius) | |
| reward_arrival = 0.0 | |
| if dist_xy < self.goal_arrival_radius: | |
| self.goals_completed += 1 | |
| # Escalating jackpot for chained consecutive goals: 50 for 1st, 85 for 2nd, 120 for 3rd, 155 for 4th... | |
| reward_arrival = 50.0 + 35.0 * (self.goals_completed - 1) | |
| self._sample_new_goal(is_initial=False) | |
| target_pos = self.goal_pos | |
| delta_target = target_pos - torso_xpos | |
| dist_to_target = float(np.linalg.norm(delta_target)) | |
| dist_xy = float(np.linalg.norm(delta_target[:2])) | |
| self.prev_dist_to_goal = dist_to_target | |
| dist_delta = 0.0 | |
| else: | |
| dist_delta = self.prev_dist_to_goal - dist_to_target | |
| self.prev_dist_to_goal = dist_to_target | |
| # Leeway period active after goal completion to allow decelerating & turning | |
| in_leeway = (self.goal_transition_steps > 0) | |
| if in_leeway: | |
| self.goal_transition_steps -= 1 | |
| # While turning during leeway, continuously update closest distance baseline to current position | |
| self.min_dist_to_goal = dist_to_target | |
| # 1. Distance progress potential (scaled with goal streak) & sharp penalty for moving away | |
| streak_mult = 1.0 + 0.35 * min(4, self.goals_completed) | |
| if dist_delta >= 0.0: | |
| reward_dist_progress = 3.0 * streak_mult * dist_delta | |
| else: | |
| # Double penalty for moving further away from target outside of turn leeway | |
| reward_dist_progress = 0.0 if in_leeway else (6.0 * dist_delta) | |
| # 2. Penalty for drifting/retreating beyond the closest distance achieved so far (muted in leeway) | |
| retreat_distance = max(0.0, dist_to_target - self.min_dist_to_goal) | |
| penalty_retreat = 0.0 if in_leeway else (-3.0 * retreat_distance if retreat_distance > 0.10 else 0.0) | |
| unit_target_dir = delta_target / max(0.1, dist_to_target) | |
| vel_towards_target = float(np.dot(torso_cvel[3:], unit_target_dir)) | |
| torso_speed = float(np.linalg.norm(torso_cvel[3:])) | |
| # 3. Controlled Forward Movement vs Severe Retrograde Penalty | |
| # Cap forward reward at 1.8 m/s so Olympic sprinting gives no extra reward | |
| v_eff = min(max(0.0, vel_towards_target), 1.8) | |
| reward_forward = 3.0 * v_eff * upright_scale | |
| penalty_retrograde = 0.0 if (vel_towards_target >= 0.0 or in_leeway) else (5.0 * vel_towards_target) | |
| # 4. Heading guidance velocity (steep negative slope when heading backwards outside leeway) | |
| if vel_towards_target >= 0.0: | |
| reward_heading = 1.5 * v_eff * upright_scale | |
| else: | |
| reward_heading = 0.0 if in_leeway else (3.0 * vel_towards_target) | |
| # 5. Speed Regulation Penalty: Penalize reckless overspeeding (> 2.0 m/s) to promote stable locomotion | |
| penalty_overspeed = 0.0 | |
| if torso_speed > 2.0: | |
| penalty_overspeed = -3.0 * ((torso_speed - 2.0) ** 2) | |
| # 6. Turn alignment & braking reward during transition leeway: encourage slowing down & rapid yaw alignment to new goal | |
| reward_turn = 0.0 | |
| reward_brake = 0.0 | |
| if in_leeway: | |
| torso_fwd = torso_xmat[:, 0] | |
| cos_torso_goal = float(np.dot(torso_fwd[:2], unit_target_dir[:2])) | |
| # Reward rotating body to face new target (+0.0 to +1.0) | |
| reward_turn = 1.0 * max(0.0, cos_torso_goal) | |
| # Reward decelerating below 1.6 m/s so the agent can pivot cleanly | |
| if torso_speed < 1.6: | |
| reward_brake = 0.6 * (1.6 - torso_speed) | |
| # 7. Subsequent Goal Urgency: Time-decay pressure once Goal 1 is cleared | |
| penalty_urgency = 0.0 | |
| if self.goals_completed >= 1 and not in_leeway: | |
| penalty_urgency = -0.08 * min(3, self.goals_completed) | |
| reward_wp = ( | |
| reward_arrival + | |
| reward_dist_progress + | |
| penalty_retreat + | |
| penalty_retrograde + | |
| penalty_overspeed + | |
| reward_turn + | |
| reward_brake + | |
| penalty_urgency | |
| ) | |
| reward_drift = 0.0 | |
| reward_milestone = 0.0 | |
| dist_to_wp = dist_to_target | |
| else: | |
| # Obstacle course mode | |
| wp_pos, wp_name, wp_radius = COURSE_WAYPOINTS[self.current_wp_idx] | |
| target_pos = wp_pos | |
| delta_to_wp = wp_pos - torso_xpos | |
| dist_to_wp = float(np.linalg.norm(delta_to_wp)) | |
| dist_xy = float(np.linalg.norm(delta_to_wp[:2])) | |
| reward_forward = 3.5 * vx * upright_scale | |
| if vx < -0.05: | |
| reward_forward += 1.5 * vx | |
| wp_reached = False | |
| if dist_xy < wp_radius: | |
| wp_reached = True | |
| elif torso_xpos[0] >= wp_pos[0] and abs(torso_xpos[1] - wp_pos[1]) < (wp_radius + 0.3): | |
| wp_reached = True | |
| reward_wp = 0.0 | |
| if wp_reached and self.current_wp_idx not in self.waypoints_reached: | |
| self.waypoints_reached.add(self.current_wp_idx) | |
| if self.current_wp_idx == len(COURSE_WAYPOINTS) - 1: | |
| reward_wp = 100.0 | |
| elif 3 <= self.current_wp_idx <= 14: | |
| reward_wp = 25.0 | |
| else: | |
| reward_wp = 15.0 | |
| if self.current_wp_idx < len(COURSE_WAYPOINTS) - 1: | |
| self.current_wp_idx += 1 | |
| wp_pos, wp_name, wp_radius = COURSE_WAYPOINTS[self.current_wp_idx] | |
| target_pos = wp_pos | |
| delta_to_wp = wp_pos - torso_xpos | |
| dist_to_wp = float(np.linalg.norm(delta_to_wp)) | |
| wp_unit_dir = delta_to_wp / max(0.1, dist_to_wp) | |
| vel_towards_wp = float(np.dot(torso_cvel[3:], wp_unit_dir)) | |
| reward_heading = 2.0 * max(-0.5, min(vel_towards_wp, 3.5)) * upright_scale | |
| target_y = float(wp_pos[1]) | |
| reward_drift = -0.5 * abs(y - target_y) | |
| reward_milestone = 0.0 | |
| for threshold, name in STAGE_MILESTONES: | |
| if x >= threshold and threshold not in self.milestones_reached: | |
| self.milestones_reached.add(threshold) | |
| reward_milestone += 50.0 | |
| # Head Gaze Alignment Reward: mild reward for facing objective with head/visor | |
| head_xmat = self.data.xmat[self.head_body_id].reshape(3, 3) | |
| head_fwd = head_xmat[:, 0] # Local X axis of head (forward gaze direction) | |
| head_pos = self.data.xpos[self.head_body_id] | |
| delta_head_target = target_pos - head_pos | |
| dist_head_target = float(np.linalg.norm(delta_head_target)) | |
| u_head_target = delta_head_target / max(1e-3, dist_head_target) | |
| cos_gaze = float(np.dot(head_fwd, u_head_target)) | |
| reward_gaze = 0.15 * max(0.0, cos_gaze) | |
| # 2. Upright posture reward (staying balanced above 0.70m) | |
| reward_upright = 1.0 if z > 0.70 else 0.0 | |
| # 3. Upright orientation bonus & active lateral roll stabilization | |
| roll_y = torso_xmat[1, 2] | |
| reward_roll = -2.0 * abs(roll_y) | |
| reward_orient = 0.8 * max(0.0, up_z) + reward_roll | |
| # Advance speed for biomechanical stepping gating | |
| forward_vec = torso_xmat[:, 0] | |
| v_advance = float(np.dot(torso_cvel[3:6], forward_vec)) if self.arena_mode else vx | |
| # 4. Alternating Bipedal Stepping Cadence | |
| reward_gait = 0.0 | |
| if z > 0.55 and v_advance > 0.10: | |
| if touch_r ^ touch_l: | |
| reward_gait = 2.0 * min(v_advance, 1.5) | |
| elif touch_r and touch_l: | |
| reward_gait = -0.4 * min(v_advance, 1.5) | |
| # 5. Knee Articulation & Stance Stability | |
| r_knee_q = self.data.qpos[self.r_knee_qpos] | |
| l_knee_q = self.data.qpos[self.l_knee_qpos] | |
| reward_knee = 0.0 | |
| if z > 0.55 and v_advance > 0.10: | |
| if not touch_r: | |
| if 0.25 <= r_knee_q <= 1.1: | |
| reward_knee += 0.3 | |
| elif r_knee_q > 1.6: | |
| reward_knee -= 0.5 * (r_knee_q - 1.6) | |
| if not touch_l: | |
| if 0.25 <= l_knee_q <= 1.1: | |
| reward_knee += 0.3 | |
| elif l_knee_q > 1.6: | |
| reward_knee -= 0.5 * (l_knee_q - 1.6) | |
| if touch_r and r_knee_q < 0.50: | |
| reward_knee += 0.2 | |
| if touch_l and l_knee_q < 0.50: | |
| reward_knee += 0.2 | |
| # 6. Swing foot forward velocity bonus | |
| r_foot_vfwd = float(np.dot(self.data.cvel[self.r_foot_id][3:6], forward_vec)) if self.arena_mode else self.data.cvel[self.r_foot_id][3] | |
| l_foot_vfwd = float(np.dot(self.data.cvel[self.l_foot_id][3:6], forward_vec)) if self.arena_mode else self.data.cvel[self.l_foot_id][3] | |
| reward_swing = 0.0 | |
| if z > 0.55 and v_advance > 0.10: | |
| if not touch_r and r_foot_vfwd > 0.15: | |
| reward_swing += 0.3 * min(r_foot_vfwd, 2.0) | |
| if not touch_l and l_foot_vfwd > 0.15: | |
| reward_swing += 0.3 * min(l_foot_vfwd, 2.0) | |
| # 7. Thigh Forward Stride Separation | |
| r_hip_q = self.data.qpos[self.r_hip_qpos] | |
| l_hip_q = self.data.qpos[self.l_hip_qpos] | |
| reward_stride = 0.0 | |
| if z > 0.55 and v_advance > 0.10: | |
| if not touch_r: | |
| stride_diff = l_hip_q - r_hip_q | |
| if 0.1 < stride_diff < 1.3: | |
| reward_stride = 1.0 * stride_diff | |
| elif not touch_l: | |
| stride_diff = r_hip_q - l_hip_q | |
| if 0.1 < stride_diff < 1.3: | |
| reward_stride = 1.0 * stride_diff | |
| # 8. Bilateral Stance Symmetry (Duty-Cycle Equalizer) | |
| reward_symmetry = 0.0 | |
| total_contacts = self.r_foot_contact_steps + self.l_foot_contact_steps | |
| if self.current_step > 10 and total_contacts > 6 and z > 0.55: | |
| asym = abs(self.r_foot_contact_steps - self.l_foot_contact_steps) / total_contacts | |
| if asym > 0.20: | |
| reward_symmetry = -2.0 * (asym - 0.20) | |
| # 9. Arm Windmilling Suppression (Penalize flailing arms to force balance from core and legs) | |
| arm_flail_penalty = -0.008 * float(np.sum(np.square(action[15:21]))) | |
| # 10. Energy expenditure penalty | |
| reward_ctrl = -0.0015 * torque_cost + arm_flail_penalty | |
| total_reward = ( | |
| reward_forward + | |
| reward_heading + | |
| reward_wp + | |
| reward_gaze + | |
| reward_upright + | |
| reward_orient + | |
| reward_gait + | |
| reward_knee + | |
| reward_swing + | |
| reward_stride + | |
| reward_symmetry + | |
| reward_ctrl + | |
| reward_drift + | |
| reward_milestone | |
| ) | |
| if self.arena_mode: | |
| rad_dist = float(np.linalg.norm([x, y])) | |
| if rad_dist > self.furthest_x: | |
| self.furthest_x = rad_dist | |
| if in_leeway: | |
| # During turning leeway window: reset & do not accumulate moving-away or stagnation counters | |
| self.steps_not_approaching = 0 | |
| self.negative_vx_steps = 0 | |
| self.steps_since_progress = 0 | |
| self.min_dist_to_goal = dist_to_target | |
| else: | |
| # Active approach check: requires advancing toward target (> 0.15 m/s and closing distance) | |
| is_actively_approaching = (vel_towards_target >= 0.15) and (dist_delta > 0.001) | |
| if is_actively_approaching: | |
| self.steps_not_approaching = 0 | |
| else: | |
| self.steps_not_approaching += 1 | |
| # Receding tracker: moving away from target | |
| is_receding = (vel_towards_target < -0.05) or (dist_delta < -0.003) | |
| if is_receding: | |
| self.negative_vx_steps += 1 | |
| else: | |
| self.negative_vx_steps = max(0, self.negative_vx_steps - 1) | |
| # Record best approach distance | |
| if dist_to_target < self.min_dist_to_goal: | |
| self.min_dist_to_goal = dist_to_target | |
| self.steps_since_progress = 0 | |
| else: | |
| self.steps_since_progress += 1 | |
| else: | |
| if x > self.furthest_x: | |
| self.furthest_x = x | |
| if x > self.last_progress_x + 0.06: | |
| self.last_progress_x = x | |
| self.steps_since_progress = 0 | |
| else: | |
| self.steps_since_progress += 1 | |
| if vx < -0.15: | |
| self.negative_vx_steps += 1 | |
| else: | |
| self.negative_vx_steps = 0 | |
| # ββ TERMINATION & TRUNCATION ββββββββββββββββββββββββββββββββββββββββββ | |
| # Exact preserved death conditions: | |
| # 1. Physical collision: head, visor, chest, or upper torso directly impacts the floor/obstacles | |
| # 2. True collapse: torso center of mass drops below 0.35m or head drops below 0.45m | |
| # 3. Complete inversion: torso tilted > 81 deg from vertical (up_z < 0.15) | |
| # 4. Arena boundary departure (|x| > 24m or |y| > 24m) or course lateral departure (|y| > 3.0m) | |
| # 5. Fast elimination when moving away or not actively approaching target (outside leeway) | |
| terminated = False | |
| is_obstacle_or_ground_impact = False | |
| for c_idx in range(self.data.ncon): | |
| c = self.data.contact[c_idx] | |
| g1, g2 = c.geom1, c.geom2 | |
| if (g1 in self.body_impact_geoms and g2 in self.world_obstacle_geoms) or \ | |
| (g2 in self.body_impact_geoms and g1 in self.world_obstacle_geoms): | |
| is_obstacle_or_ground_impact = True | |
| break | |
| head_z = self.data.xpos[self.head_body_id][2] | |
| is_altitude_collapse = (z < 0.35) or (head_z < 0.45) | |
| is_tilt_collapse = (up_z < 0.15) | |
| is_physical_fall = is_obstacle_or_ground_impact or is_altitude_collapse or is_tilt_collapse | |
| if self.arena_mode: | |
| is_boundary_departure = (abs(x) > 24.0 or abs(y) > 24.0) | |
| # FAST KILL TRIGGERS (active outside turning leeway period): | |
| # 1. Backing away kill: moved away from target for > 25 steps (~0.6s) | |
| is_moving_away_kill = (not in_leeway) and (self.negative_vx_steps > 25) | |
| # 2. Retreat distance kill: backed up > 0.40m further than closest approach achieved | |
| is_retreat_kill = (not in_leeway) and (dist_to_target > self.min_dist_to_goal + 0.40) and (self.current_step > 15) | |
| # 3. Not actively approaching kill: dawdling, circling, or stationary for > 80 steps (~2.0s) | |
| is_not_approaching_kill = (not in_leeway) and (self.current_step > 25 and self.steps_not_approaching > 80) | |
| # 4. Stagnation kill: no new record distance achieved for > 85 steps (~2.125s) | |
| is_stagnant_kill = (not in_leeway) and (self.current_step > 25 and self.steps_since_progress > 85) | |
| if is_physical_fall: | |
| terminated = True | |
| total_reward -= 20.0 | |
| self.health = max(0.0, self.health - 0.2) | |
| elif is_boundary_departure: | |
| terminated = True | |
| total_reward -= 20.0 | |
| elif is_moving_away_kill or is_retreat_kill: | |
| terminated = True | |
| total_reward -= 25.0 # Severe penalty for retreating/moving away from target | |
| elif is_not_approaching_kill or is_stagnant_kill: | |
| terminated = True | |
| total_reward -= 25.0 # Severe penalty for failing to actively approach target | |
| # Dropped Goal Streak Penalty: | |
| # If the agent achieved at least 1 goal, but died before reaching the next goal, | |
| # apply a moderate penalty to encourage multi-goal chaining without negating arrival returns | |
| if terminated and self.goals_completed >= 1: | |
| dropped_goal_penalty = -15.0 * (1.0 + 0.2 * min(3, self.goals_completed - 1)) | |
| total_reward += dropped_goal_penalty | |
| else: | |
| is_moving_backwards = (x < self.spawn_x - 0.50) or (self.negative_vx_steps > 60) | |
| is_stagnant = (self.current_step > 50 and self.steps_since_progress > 90) | |
| is_lateral_departure = (abs(y) > 3.0) | |
| if is_physical_fall: | |
| terminated = True | |
| total_reward -= 20.0 | |
| self.health = max(0.0, self.health - 0.2) | |
| elif is_moving_backwards: | |
| terminated = True | |
| total_reward -= 15.0 | |
| elif is_stagnant: | |
| terminated = True | |
| total_reward -= 10.0 | |
| elif is_lateral_departure: | |
| terminated = True | |
| total_reward -= 20.0 | |
| if x >= 47.0: | |
| terminated = True | |
| total_reward += 200.0 | |
| truncated = (self.current_step >= self.max_steps) | |
| info = { | |
| "x_pos": float(x), | |
| "y_pos": float(y), | |
| "z_pos": float(z), | |
| "vx": float(vx), | |
| "torso_speed": float(torso_speed), | |
| "furthest_x": float(self.furthest_x), | |
| "milestones": len(self.milestones_reached), | |
| "waypoint_idx": int(self.current_wp_idx), | |
| "waypoint_name": str(COURSE_WAYPOINTS[self.current_wp_idx][1]) if not self.arena_mode else f"Arena Target #{self.goals_completed + 1}", | |
| "dist_to_wp": float(dist_to_wp), | |
| "waypoints_completed": len(self.waypoints_reached) if not self.arena_mode else int(self.goals_completed), | |
| "goals_completed": int(self.goals_completed), | |
| "dist_to_goal": float(dist_to_wp), | |
| "goal_arrival_radius": float(self.goal_arrival_radius) if self.arena_mode else 1.0, | |
| "goal_pos": self.goal_pos.tolist() if self.arena_mode else list(COURSE_WAYPOINTS[self.current_wp_idx][0]), | |
| "target_local_pos": (torso_xmat.T @ (target_pos - torso_xpos)).tolist(), | |
| "gaze_alignment": float(cos_gaze), | |
| "stamina": float(self.stamina), | |
| "health": float(self.health), | |
| "hunger": float(self.hunger), | |
| "step": self.current_step, | |
| "lidar_grid": self.current_lidar_grid.tolist(), | |
| "in_leeway": bool(in_leeway) if self.arena_mode else False, | |
| "leeway_remaining": int(self.goal_transition_steps) if self.arena_mode else 0, | |
| "stream_mode": "cruise", | |
| "active_reach_hand": "none", | |
| "reach_touches": 0, | |
| "getup_success": False, | |
| "stand_still_score": 0.0, | |
| } | |
| return self._get_obs(), float(total_reward), terminated, truncated, info | |
| def render_frame( | |
| self, | |
| width: int = 480, | |
| height: int = 320, | |
| camera_name: str = "behind_cam", | |
| ghost_datas: Optional[List[Any]] = None, | |
| ghost_alpha: Optional[float] = None, | |
| ) -> np.ndarray: | |
| """ | |
| Renders an RGB numpy image of the current simulation frame. | |
| Optionally injects translucent ghost avatars into mjvScene for parallel multi-agent swarm visualization. | |
| """ | |
| if self._renderer is None or self._renderer.width != width or self._renderer.height != height: | |
| self._renderer = mujoco.Renderer(self.model, height=height, width=width) | |
| self._renderer.update_scene(self.data, camera=camera_name) | |
| # Inject dynamic course waypoint goal markers into mjvScene | |
| scene = self._renderer.scene | |
| identity_mat = np.eye(3).flatten() | |
| if self.stream_mode == "reach": | |
| # Stream 3 Reach: Glowing magenta hand target orb + vertical guide line | |
| rx, ry, rz = float(self.reach_target_pos[0]), float(self.reach_target_pos[1]), float(self.reach_target_pos[2]) | |
| if scene.ngeom < scene.maxgeom: | |
| dst_orb = scene.geoms[scene.ngeom] | |
| orb_pos = np.array([rx, ry, rz], dtype=np.float64) | |
| orb_size = np.array([0.09, 0.09, 0.09], dtype=np.float64) | |
| orb_rgba = np.array([0.88, 0.20, 0.95, 0.95], dtype=np.float32) # Radiant Magenta | |
| mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba) | |
| dst_orb.transparent = 1 | |
| scene.ngeom += 1 | |
| if scene.ngeom < scene.maxgeom: | |
| dst_guide = scene.geoms[scene.ngeom] | |
| guide_pos = np.array([rx, ry, max(0.01, rz / 2.0)], dtype=np.float64) | |
| guide_size = np.array([0.012, max(0.01, rz / 2.0), 0.0], dtype=np.float64) | |
| guide_rgba = np.array([0.88, 0.20, 0.95, 0.35], dtype=np.float32) | |
| mujoco.mjv_initGeom(dst_guide, mujoco.mjtGeom.mjGEOM_CYLINDER, guide_size, guide_pos, identity_mat, guide_rgba) | |
| dst_guide.transparent = 1 | |
| scene.ngeom += 1 | |
| elif self.stream_mode == "getup": | |
| # Stream 2 Get-Up: Golden upright elevation target disc | |
| torso_xpos = self.data.xpos[self.torso_body_id] | |
| tx, ty = float(torso_xpos[0]), float(torso_xpos[1]) | |
| if scene.ngeom < scene.maxgeom: | |
| dst_target = scene.geoms[scene.ngeom] | |
| target_pos = np.array([tx, ty, 1.33], dtype=np.float64) | |
| target_size = np.array([0.25, 0.02, 0.0], dtype=np.float64) | |
| target_rgba = np.array([0.98, 0.75, 0.15, 0.65], dtype=np.float32) # Glowing Amber | |
| mujoco.mjv_initGeom(dst_target, mujoco.mjtGeom.mjGEOM_CYLINDER, target_size, target_pos, identity_mat, target_rgba) | |
| dst_target.transparent = 1 | |
| scene.ngeom += 1 | |
| elif self.stream_mode == "stand": | |
| # Stream 1 Stand: Cyan stillness equilibrium ground pad | |
| if scene.ngeom < scene.maxgeom: | |
| dst_ring = scene.geoms[scene.ngeom] | |
| ring_pos = np.array([0.0, 0.0, 0.015], dtype=np.float64) | |
| ring_size = np.array([0.35, 0.015, 0.0], dtype=np.float64) | |
| ring_rgba = np.array([0.22, 0.74, 0.97, 0.50], dtype=np.float32) # Cyan Pad | |
| mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba) | |
| dst_ring.transparent = 1 | |
| scene.ngeom += 1 | |
| elif self.arena_mode: | |
| # ACTIVE ARENA GOAL: Glowing floating target orb + vertical laser beacon column + ground target ring | |
| gx, gy, gz = float(self.goal_pos[0]), float(self.goal_pos[1]), float(self.goal_pos[2]) | |
| # 1. Floating target orb (sphere) | |
| if scene.ngeom < scene.maxgeom: | |
| dst_orb = scene.geoms[scene.ngeom] | |
| orb_pos = np.array([gx, gy, gz + 0.35], dtype=np.float64) | |
| orb_size = np.array([0.22, 0.22, 0.22], dtype=np.float64) | |
| orb_rgba = np.array([0.98, 0.75, 0.15, 0.90], dtype=np.float32) # Vibrant Gold | |
| mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba) | |
| dst_orb.transparent = 1 | |
| scene.ngeom += 1 | |
| # 2. Vertical laser beacon column (cylinder) | |
| if scene.ngeom < scene.maxgeom: | |
| dst_beam = scene.geoms[scene.ngeom] | |
| beam_pos = np.array([gx, gy, 1.25], dtype=np.float64) | |
| beam_size = np.array([0.05, 1.25, 0.0], dtype=np.float64) | |
| beam_rgba = np.array([0.22, 0.74, 0.97, 0.55], dtype=np.float32) # Glowing Cyan | |
| mujoco.mjv_initGeom(dst_beam, mujoco.mjtGeom.mjGEOM_CYLINDER, beam_size, beam_pos, identity_mat, beam_rgba) | |
| dst_beam.transparent = 1 | |
| scene.ngeom += 1 | |
| # 3. Ground target arrival ring | |
| if scene.ngeom < scene.maxgeom: | |
| dst_ring = scene.geoms[scene.ngeom] | |
| ring_pos = np.array([gx, gy, 0.02], dtype=np.float64) | |
| ring_size = np.array([self.goal_arrival_radius, 0.02, 0.0], dtype=np.float64) | |
| ring_rgba = np.array([0.22, 0.74, 0.97, 0.45], dtype=np.float32) | |
| mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba) | |
| dst_ring.transparent = 1 | |
| scene.ngeom += 1 | |
| else: | |
| for wp_idx, (wp_coord, wp_name, wp_radius) in enumerate(COURSE_WAYPOINTS): | |
| if scene.ngeom + 3 >= scene.maxgeom: | |
| break | |
| wx = float(wp_coord[0]) | |
| wy = float(wp_coord[1]) | |
| wz = float(wp_coord[2]) | |
| if wp_idx == self.current_wp_idx: | |
| # ACTIVE GOAL: Glowing floating target orb + vertical laser beacon column + ground target ring | |
| # 1. Floating target orb (sphere) | |
| dst_orb = scene.geoms[scene.ngeom] | |
| orb_pos = np.array([wx, wy, wz + 0.35], dtype=np.float64) | |
| orb_size = np.array([0.18, 0.18, 0.18], dtype=np.float64) | |
| orb_rgba = np.array([0.98, 0.75, 0.15, 0.90], dtype=np.float32) # Vibrant Gold | |
| mujoco.mjv_initGeom(dst_orb, mujoco.mjtGeom.mjGEOM_SPHERE, orb_size, orb_pos, identity_mat, orb_rgba) | |
| dst_orb.transparent = 1 | |
| scene.ngeom += 1 | |
| # 2. Vertical laser beacon column (cylinder) | |
| if scene.ngeom < scene.maxgeom: | |
| dst_beam = scene.geoms[scene.ngeom] | |
| beam_pos = np.array([wx, wy, wz + 0.60], dtype=np.float64) | |
| beam_size = np.array([0.05, 0.60, 0.0], dtype=np.float64) | |
| beam_rgba = np.array([0.22, 0.74, 0.97, 0.55], dtype=np.float32) # Glowing Cyan | |
| mujoco.mjv_initGeom(dst_beam, mujoco.mjtGeom.mjGEOM_CYLINDER, beam_size, beam_pos, identity_mat, beam_rgba) | |
| dst_beam.transparent = 1 | |
| scene.ngeom += 1 | |
| # 3. Ground target landing pad ring | |
| if scene.ngeom < scene.maxgeom: | |
| dst_ring = scene.geoms[scene.ngeom] | |
| ring_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64) | |
| ring_size = np.array([float(wp_radius) * 0.6, 0.02, 0.0], dtype=np.float64) | |
| ring_rgba = np.array([0.22, 0.74, 0.97, 0.45], dtype=np.float32) | |
| mujoco.mjv_initGeom(dst_ring, mujoco.mjtGeom.mjGEOM_CYLINDER, ring_size, ring_pos, identity_mat, ring_rgba) | |
| dst_ring.transparent = 1 | |
| scene.ngeom += 1 | |
| elif wp_idx < self.current_wp_idx: | |
| # COMPLETED GOAL: Subtle emerald disc on surface | |
| dst_comp = scene.geoms[scene.ngeom] | |
| comp_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64) | |
| comp_size = np.array([0.25, 0.015, 0.0], dtype=np.float64) | |
| comp_rgba = np.array([0.10, 0.85, 0.35, 0.40], dtype=np.float32) # Emerald Green | |
| mujoco.mjv_initGeom(dst_comp, mujoco.mjtGeom.mjGEOM_CYLINDER, comp_size, comp_pos, identity_mat, comp_rgba) | |
| dst_comp.transparent = 1 | |
| scene.ngeom += 1 | |
| else: | |
| # UPCOMING GOAL: Translucent waypoint marker disc | |
| dst_up = scene.geoms[scene.ngeom] | |
| up_pos = np.array([wx, wy, max(0.01, wz - 0.05)], dtype=np.float64) | |
| up_size = np.array([0.16, 0.015, 0.0], dtype=np.float64) | |
| up_rgba = np.array([0.60, 0.65, 0.80, 0.25], dtype=np.float32) # Translucent Silver | |
| mujoco.mjv_initGeom(dst_up, mujoco.mjtGeom.mjGEOM_CYLINDER, up_size, up_pos, identity_mat, up_rgba) | |
| dst_up.transparent = 1 | |
| scene.ngeom += 1 | |
| # Inject translucent ghost avatars if provided | |
| if ghost_datas: | |
| scene = self._renderer.scene | |
| for g_idx, g_data in enumerate(ghost_datas): | |
| color = GHOST_PALETTE[g_idx % len(GHOST_PALETTE)] | |
| if ghost_alpha is not None: | |
| color = color.copy() | |
| color[3] = float(ghost_alpha) | |
| for gid in self.humanoid_geoms: | |
| if scene.ngeom >= scene.maxgeom: | |
| break | |
| dst = scene.geoms[scene.ngeom] | |
| pos = g_data.geom_xpos[gid].astype(np.float64) | |
| size = self.model.geom_size[gid].astype(np.float64) | |
| mat = g_data.geom_xmat[gid].astype(np.float64) | |
| geom_type = int(self.model.geom_type[gid]) | |
| mujoco.mjv_initGeom(dst, geom_type, size, pos, mat, color) | |
| dst.transparent = 1 | |
| scene.ngeom += 1 | |
| return self._renderer.render() | |