Timsty commited on
Commit
700dd75
·
verified ·
1 Parent(s): 87c8483

Add files using upload-large-folder tool

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. GR00T-WholeBodyControl/.github/workflows/docs.yml +74 -0
  2. GR00T-WholeBodyControl/decoupled_wbc/control/__init__.py +0 -0
  3. GR00T-WholeBodyControl/decoupled_wbc/control/base/__init__.py +0 -0
  4. GR00T-WholeBodyControl/decoupled_wbc/control/base/env.py +45 -0
  5. GR00T-WholeBodyControl/decoupled_wbc/control/base/humanoid_env.py +60 -0
  6. GR00T-WholeBodyControl/decoupled_wbc/control/base/policy.py +47 -0
  7. GR00T-WholeBodyControl/decoupled_wbc/control/base/sensor.py +35 -0
  8. GR00T-WholeBodyControl/decoupled_wbc/control/envs/__init__.py +0 -0
  9. GR00T-WholeBodyControl/decoupled_wbc/control/main/__init__.py +0 -0
  10. GR00T-WholeBodyControl/decoupled_wbc/control/main/config_template.py +45 -0
  11. GR00T-WholeBodyControl/decoupled_wbc/control/main/constants.py +16 -0
  12. GR00T-WholeBodyControl/decoupled_wbc/control/policy/__init__.py +0 -0
  13. GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_decoupled_whole_body_policy.py +157 -0
  14. GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_gear_wbc_policy.py +295 -0
  15. GR00T-WholeBodyControl/decoupled_wbc/control/policy/identity_policy.py +25 -0
  16. GR00T-WholeBodyControl/decoupled_wbc/control/policy/interpolation_policy.py +297 -0
  17. GR00T-WholeBodyControl/decoupled_wbc/control/policy/keyboard_navigation_policy.py +87 -0
  18. GR00T-WholeBodyControl/decoupled_wbc/control/policy/lerobot_replay_policy.py +111 -0
  19. GR00T-WholeBodyControl/decoupled_wbc/control/policy/teleop_policy.py +207 -0
  20. GR00T-WholeBodyControl/decoupled_wbc/control/policy/wbc_policy_factory.py +65 -0
  21. GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/__init__.py +3 -0
  22. GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/robot_model.py +772 -0
  23. GR00T-WholeBodyControl/decoupled_wbc/control/sensor/__init__.py +0 -0
  24. GR00T-WholeBodyControl/decoupled_wbc/control/sensor/composed_camera.py +440 -0
  25. GR00T-WholeBodyControl/decoupled_wbc/control/sensor/oak.py +324 -0
  26. GR00T-WholeBodyControl/decoupled_wbc/control/sensor/sensor_server.py +128 -0
  27. GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_retargeting_ik.py +148 -0
  28. GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_streamer.py +240 -0
  29. GR00T-WholeBodyControl/decoupled_wbc/control/utils/__init__.py +0 -0
  30. GR00T-WholeBodyControl/decoupled_wbc/control/utils/cv_bridge.py +396 -0
  31. GR00T-WholeBodyControl/decoupled_wbc/control/utils/episode_state.py +32 -0
  32. GR00T-WholeBodyControl/decoupled_wbc/control/utils/gear_wbc_utils.py +100 -0
  33. GR00T-WholeBodyControl/decoupled_wbc/control/utils/keyboard_dispatcher.py +255 -0
  34. GR00T-WholeBodyControl/decoupled_wbc/control/utils/network_utils.py +137 -0
  35. GR00T-WholeBodyControl/decoupled_wbc/control/utils/ros_utils.py +201 -0
  36. GR00T-WholeBodyControl/decoupled_wbc/control/utils/run_real_checklist.py +121 -0
  37. GR00T-WholeBodyControl/decoupled_wbc/control/utils/service.py +182 -0
  38. GR00T-WholeBodyControl/decoupled_wbc/control/utils/term_color_constants.py +19 -0
  39. GR00T-WholeBodyControl/decoupled_wbc/control/utils/text_to_speech.py +28 -0
  40. GR00T-WholeBodyControl/decoupled_wbc/control/visualization/humanoid_visualizer.py +52 -0
  41. GR00T-WholeBodyControl/decoupled_wbc/control/visualization/meshcat_visualizer_env.py +77 -0
  42. GR00T-WholeBodyControl/decoupled_wbc/data/constants.py +5 -0
  43. GR00T-WholeBodyControl/decoupled_wbc/data/exporter.py +514 -0
  44. GR00T-WholeBodyControl/decoupled_wbc/data/utils.py +156 -0
  45. GR00T-WholeBodyControl/decoupled_wbc/data/video_writer.py +102 -0
  46. GR00T-WholeBodyControl/decoupled_wbc/data/viz/rerun_viz.py +213 -0
  47. GR00T-WholeBodyControl/decoupled_wbc/docker/.bashrc +163 -0
  48. GR00T-WholeBodyControl/decoupled_wbc/docker/.tmux.conf +84 -0
  49. GR00T-WholeBodyControl/decoupled_wbc/docker/70-manus-hid.rules +5 -0
  50. GR00T-WholeBodyControl/decoupled_wbc/docker/Dockerfile.deploy +130 -0
GR00T-WholeBodyControl/.github/workflows/docs.yml ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Build and Deploy Documentation
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - gear-sonic
8
+ paths:
9
+ - "docs/**"
10
+ - ".github/workflows/docs.yml"
11
+ - ".gitattributes"
12
+ workflow_dispatch:
13
+
14
+ # Allow only one concurrent deployment; cancel in-flight runs
15
+ concurrency:
16
+ group: "pages"
17
+ cancel-in-progress: true
18
+
19
+ jobs:
20
+ build:
21
+ name: Build Sphinx Docs
22
+ runs-on: ubuntu-latest
23
+ steps:
24
+ - name: Checkout repository
25
+ uses: actions/checkout@v4
26
+ with:
27
+ lfs: false
28
+
29
+ - name: Restore docs static assets (bypass git-lfs smudge)
30
+ run: |
31
+ # git-lfs on the runner rewrites files tracked by *.png/*.gif
32
+ # even when our .gitattributes override removes filter=lfs.
33
+ # Use git cat-file to write real binary content directly from
34
+ # the object store, bypassing all smudge filters.
35
+ git ls-tree -r HEAD -- docs/source/_static \
36
+ | awk '{print $3, $4}' \
37
+ | while IFS=" " read -r hash path; do
38
+ git cat-file blob "$hash" > "$path"
39
+ done
40
+
41
+ - name: Set up Python
42
+ uses: actions/setup-python@v5
43
+ with:
44
+ python-version: "3.10"
45
+ cache: "pip"
46
+ cache-dependency-path: "docs/requirements.txt"
47
+
48
+ - name: Install documentation dependencies
49
+ run: pip install -r docs/requirements.txt
50
+
51
+ - name: Build HTML documentation
52
+ run: sphinx-build -b html docs/source docs/build/html
53
+
54
+ - name: Upload Pages artifact
55
+ if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gear-sonic'
56
+ uses: actions/upload-pages-artifact@v3
57
+ with:
58
+ path: docs/build/html
59
+
60
+ deploy:
61
+ name: Deploy to GitHub Pages
62
+ needs: build
63
+ if: github.ref == 'refs/heads/main' || github.ref == 'refs/heads/gear-sonic'
64
+ runs-on: ubuntu-latest
65
+ permissions:
66
+ pages: write
67
+ id-token: write
68
+ environment:
69
+ name: github-pages
70
+ url: ${{ steps.deployment.outputs.page_url }}
71
+ steps:
72
+ - name: Deploy to GitHub Pages
73
+ id: deployment
74
+ uses: actions/deploy-pages@v4
GR00T-WholeBodyControl/decoupled_wbc/control/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/base/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/base/env.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gymnasium as gym
2
+
3
+
4
+ class Env:
5
+ """Base interface for all environments in the Gr00t framework"""
6
+
7
+ def observe(self) -> dict[str, any]:
8
+ """Read the current state of this environment
9
+
10
+ Returns:
11
+ dict: A dictionary of observations
12
+ """
13
+ pass
14
+
15
+ def queue_action(self, action: dict[str, any]):
16
+ """Queue an action to be executed
17
+
18
+ Args:
19
+ action: A dictionary of action parameters
20
+ """
21
+ pass
22
+
23
+ def reset(self, **kwargs):
24
+ """Reset this environment to initial state"""
25
+ pass
26
+
27
+ def observation_space(self) -> gym.Space:
28
+ """Get the observation space of this environment
29
+
30
+ Returns:
31
+ gym.Space: The observation space
32
+ """
33
+ pass
34
+
35
+ def action_space(self) -> gym.Space:
36
+ """Get the action space of this environment
37
+
38
+ Returns:
39
+ gym.Space: The action space
40
+ """
41
+ pass
42
+
43
+ def close(self):
44
+ """Close and clean up this environment"""
45
+ pass
GR00T-WholeBodyControl/decoupled_wbc/control/base/humanoid_env.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import abstractmethod
2
+
3
+ from decoupled_wbc.control.base.env import Env
4
+ from decoupled_wbc.control.base.sensor import Sensor
5
+ from decoupled_wbc.control.robot_model.robot_model import RobotModel
6
+
7
+
8
+ class Hands:
9
+ """Container class for left and right hand environments.
10
+
11
+ Attributes:
12
+ left: Environment for the left hand
13
+ right: Environment for the right hand
14
+ """
15
+
16
+ left: Env
17
+ right: Env
18
+
19
+
20
+ class HumanoidEnv(Env):
21
+ """Base class for humanoid robot environments.
22
+
23
+ This class provides the interface for accessing the robot's body, hands, and sensors.
24
+ """
25
+
26
+ def body(self) -> Env:
27
+ """Get the robot's body environment.
28
+
29
+ Returns:
30
+ Env: The body environment
31
+ """
32
+ pass
33
+
34
+ def hands(self) -> Hands:
35
+ """Get the robot's hands.
36
+
37
+ Returns:
38
+ Hands: Container with left and right hand environments
39
+ """
40
+ pass
41
+
42
+ def sensors(self) -> dict[str, Sensor]:
43
+ """Get the sensors of this environment
44
+
45
+ Returns:
46
+ dict: A dictionary of sensors
47
+ """
48
+ pass
49
+
50
+ @abstractmethod
51
+ def robot_model(self) -> RobotModel:
52
+ """Get the robot model of this environment
53
+ This robot model is used to dispatch whole body actions to body
54
+ and hand actuators and to reconstruct proprioceptive
55
+ observations from body and hands.
56
+
57
+ Returns:
58
+ RobotModel: The robot model
59
+ """
60
+ pass
GR00T-WholeBodyControl/decoupled_wbc/control/base/policy.py ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from abc import ABC, abstractmethod
2
+ from typing import Optional
3
+
4
+
5
+ class Policy(ABC):
6
+ """Base class for implementing control policies in the Gr00t framework.
7
+
8
+ A Policy defines how an agent should behave in an environment by mapping observations
9
+ to actions. This abstract base class provides the interface that all concrete policy
10
+ implementations must follow.
11
+ """
12
+
13
+ def set_goal(self, goal: dict[str, any]):
14
+ """Set the command from the planner that the policy should follow.
15
+
16
+ Args:
17
+ goal: Dictionary containing high-level commands or goals from the planner
18
+ """
19
+ pass
20
+
21
+ def set_observation(self, observation: dict[str, any]):
22
+ """Update the policy's current observation of the environment.
23
+
24
+ Args:
25
+ observation: Dictionary containing the current state/observation of the environment
26
+ """
27
+ self.observation = observation
28
+
29
+ @abstractmethod
30
+ def get_action(self, time: Optional[float] = None) -> dict[str, any]:
31
+ """Compute and return the next action at the specified time, based on current observation
32
+ and planner command.
33
+
34
+ Args:
35
+ time: Optional "monotonic time" for time-dependent policies
36
+
37
+ Returns:
38
+ Dictionary containing the action to be executed
39
+ """
40
+
41
+ def close(self):
42
+ """Clean up any resources used by the policy."""
43
+ pass
44
+
45
+ def reset(self):
46
+ """Reset the policy to its initial state."""
47
+ pass
GR00T-WholeBodyControl/decoupled_wbc/control/base/sensor.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import gymnasium as gym
2
+
3
+
4
+ class Sensor:
5
+ """Base class for implementing sensors in the Gr00t framework.
6
+
7
+ A Sensor provides information about a specific sensor on the robot (e.g. camera, IMU,
8
+ force sensor). This abstract base class defines the interface that all concrete sensor
9
+ implementations must follow.
10
+ """
11
+
12
+ def read(self, **kwargs) -> any:
13
+ """Read the current sensor value.
14
+
15
+ Args:
16
+ **kwargs: Additional parameters specific to the sensor implementation
17
+ (e.g. camera resolution, sampling rate)
18
+
19
+ Returns:
20
+ The sensor reading value (e.g. image data, acceleration measurements)
21
+ """
22
+ pass
23
+
24
+ def observation_space(self) -> gym.Space:
25
+ """Get the observation space of this sensor.
26
+
27
+ Returns:
28
+ gym.Space: The observation space defining the shape and bounds of sensor readings
29
+ (e.g. image dimensions for camera, measurement ranges for IMU)
30
+ """
31
+ pass
32
+
33
+ def close(self):
34
+ """Clean up any resources used by the sensor."""
35
+ pass
GR00T-WholeBodyControl/decoupled_wbc/control/envs/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/main/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/main/config_template.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from dataclasses import asdict, dataclass
2
+ from typing import Any
3
+
4
+
5
+ @dataclass
6
+ class ArgsConfig:
7
+ """Args Config for running the data collection loop."""
8
+
9
+ def update(
10
+ self,
11
+ config_dict: dict,
12
+ strict: bool = False,
13
+ skip_keys: list[str] = [],
14
+ allowed_keys: list[str] | None = None,
15
+ ):
16
+ for k, v in config_dict.items():
17
+ if k in skip_keys:
18
+ continue
19
+ if allowed_keys is not None and k not in allowed_keys:
20
+ continue
21
+ if strict and not hasattr(self, k):
22
+ raise ValueError(f"Config {k} not found in {self.__class__.__name__}")
23
+ if not strict and not hasattr(self, k):
24
+ continue
25
+ setattr(self, k, v)
26
+
27
+ @classmethod
28
+ def from_dict(
29
+ cls,
30
+ config_dict: dict,
31
+ strict: bool = False,
32
+ skip_keys: list[str] = [],
33
+ allowed_keys: list[str] | None = None,
34
+ ):
35
+ instance = cls()
36
+ instance.update(
37
+ config_dict=config_dict, strict=strict, skip_keys=skip_keys, allowed_keys=allowed_keys
38
+ )
39
+ return instance
40
+
41
+ def to_dict(self):
42
+ return asdict(self)
43
+
44
+ def get(self, key: str, default: Any = None):
45
+ return getattr(self, key) if hasattr(self, key) else default
GR00T-WholeBodyControl/decoupled_wbc/control/main/constants.py ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ IMAGE_TOPIC_NAME = "realsense/color/image_raw"
2
+ STATE_TOPIC_NAME = "G1Env/env_state_act"
3
+ CONTROL_GOAL_TOPIC = "ControlPolicy/upper_body_pose"
4
+ ROBOT_CONFIG_TOPIC = "WBCPolicy/robot_config"
5
+ KEYBOARD_INPUT_TOPIC = "/keyboard_input"
6
+ LOCO_MANIP_TASK_STATUS_TOPIC = "LocoManipPolicy/task_status"
7
+ LOCO_NAV_TASK_STATUS_TOPIC = "NavigationPolicy/task_status"
8
+ LOWER_BODY_POLICY_STATUS_TOPIC = "ControlPolicy/lower_body_policy_status"
9
+ JOINT_SAFETY_STATUS_TOPIC = "ControlPolicy/joint_safety_status"
10
+
11
+
12
+ DEFAULT_NAV_CMD = [0.0, 0.0, 0.0]
13
+ DEFAULT_BASE_HEIGHT = 0.74
14
+ DEFAULT_WRIST_POSE = [0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0] * 2 # x, y, z + w, x, y, z
15
+
16
+ DEFAULT_MODEL_SERVER_PORT = 5555 # port used to host the model server
GR00T-WholeBodyControl/decoupled_wbc/control/policy/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_decoupled_whole_body_policy.py ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time as time_module
2
+ from typing import Optional
3
+
4
+ import numpy as np
5
+ from pinocchio import rpy
6
+
7
+ from decoupled_wbc.control.base.policy import Policy
8
+ from decoupled_wbc.control.main.constants import DEFAULT_NAV_CMD
9
+
10
+
11
+ class G1DecoupledWholeBodyPolicy(Policy):
12
+ """
13
+ This class implements a whole-body policy for the G1 robot by combining an upper-body
14
+ policy and a lower-body RL-based policy.
15
+ It is designed to work with the G1 robot's specific configuration and control requirements.
16
+ """
17
+
18
+ def __init__(
19
+ self,
20
+ robot_model,
21
+ lower_body_policy: Policy,
22
+ upper_body_policy: Policy,
23
+ ):
24
+ self.robot_model = robot_model
25
+ self.lower_body_policy = lower_body_policy
26
+ self.upper_body_policy = upper_body_policy
27
+ self.last_goal_time = time_module.monotonic()
28
+ self.is_in_teleop_mode = False # Track if lower body is in teleop mode
29
+
30
+ def set_observation(self, observation):
31
+ # Upper body policy is open loop (just interpolation), so we don't need to set the observation
32
+ self.lower_body_policy.set_observation(observation)
33
+
34
+ def set_goal(self, goal):
35
+ """
36
+ Set the goal for both upper and lower body policies.
37
+
38
+ Args:
39
+ goal: Command from the planners
40
+ goal["target_upper_body_pose"]: Target pose for the upper body policy
41
+ goal["target_time"]: Target goal time
42
+ goal["interpolation_garbage_collection_time"]: Waypoints earlier than this time are removed
43
+ goal["navigate_cmd"]: Target navigation velocities for the lower body policy
44
+ goal["base_height_command"]: Target base height for both upper and lower body policies
45
+ """
46
+ # Update goal timestamp for timeout safety
47
+ self.last_goal_time = time_module.monotonic()
48
+
49
+ upper_body_goal = {}
50
+ lower_body_goal = {}
51
+
52
+ # Upper body goal keys
53
+ upper_body_keys = [
54
+ "target_upper_body_pose",
55
+ "base_height_command",
56
+ "target_time",
57
+ "interpolation_garbage_collection_time",
58
+ "navigate_cmd",
59
+ ]
60
+ for key in upper_body_keys:
61
+ if key in goal:
62
+ upper_body_goal[key] = goal[key]
63
+
64
+ # Always ensure navigate_cmd is present to prevent interpolation from old dangerous values
65
+ if "navigate_cmd" not in goal:
66
+ # Safety: Inject safe default navigate_cmd to ensure interpolation goes to stop
67
+ if "target_time" in goal and isinstance(goal["target_time"], list):
68
+ upper_body_goal["navigate_cmd"] = [np.array(DEFAULT_NAV_CMD)] * len(
69
+ goal["target_time"]
70
+ )
71
+ else:
72
+ upper_body_goal["navigate_cmd"] = np.array(DEFAULT_NAV_CMD)
73
+
74
+ # Set teleop policy command flag
75
+ has_teleop_commands = ("navigate_cmd" in goal) or ("base_height_command" in goal)
76
+ self.is_in_teleop_mode = has_teleop_commands # Track teleop state for timeout safety
77
+ self.lower_body_policy.set_use_teleop_policy_cmd(has_teleop_commands)
78
+
79
+ # Lower body goal keys
80
+ lower_body_keys = [
81
+ "toggle_stand_command",
82
+ "toggle_policy_action",
83
+ ]
84
+ for key in lower_body_keys:
85
+ if key in goal:
86
+ lower_body_goal[key] = goal[key]
87
+
88
+ self.upper_body_policy.set_goal(upper_body_goal)
89
+ self.lower_body_policy.set_goal(lower_body_goal)
90
+
91
+ def get_action(self, time: Optional[float] = None):
92
+ current_time = time if time is not None else time_module.monotonic()
93
+
94
+ # Safety timeout: Only apply when in teleop mode (communication loss dangerous)
95
+ # When in keyboard mode, no timeout needed (user controls directly)
96
+ if self.is_in_teleop_mode:
97
+ time_since_goal = current_time - self.last_goal_time
98
+ if time_since_goal > 1.0: # 1 second timeout
99
+ print(
100
+ f"SAFETY: Teleop mode timeout after {time_since_goal:.1f}s, injecting safe goal"
101
+ )
102
+ # Inject safe goal to trigger all safety mechanisms (gear_wbc reset + interpolation reset)
103
+ safe_goal = {
104
+ "target_time": current_time + 0.1,
105
+ "interpolation_garbage_collection_time": current_time - 1.0,
106
+ }
107
+ self.set_goal(
108
+ safe_goal
109
+ ) # This will reset is_in_teleop_mode to False and trigger all safety
110
+
111
+ # Get indices for groups
112
+ lower_body_indices = self.robot_model.get_joint_group_indices("lower_body")
113
+ upper_body_indices = self.robot_model.get_joint_group_indices("upper_body")
114
+
115
+ # Initialize full configuration with zeros
116
+ q = np.zeros(self.robot_model.num_dofs)
117
+
118
+ upper_body_action = self.upper_body_policy.get_action(time)
119
+ q[upper_body_indices] = upper_body_action["target_upper_body_pose"]
120
+ q_arms = q[self.robot_model.get_joint_group_indices("arms")]
121
+ base_height_command = upper_body_action.get("base_height_command", None)
122
+ interpolated_navigate_cmd = upper_body_action.get("navigate_cmd", None)
123
+
124
+ # Compute torso orientation relative to waist, to pass to lower body policy
125
+ self.robot_model.cache_forward_kinematics(q, auto_clip=False)
126
+ torso_orientation = self.robot_model.frame_placement("torso_link").rotation
127
+ waist_orientation = self.robot_model.frame_placement("pelvis").rotation
128
+ # Extract yaw from rotation matrix and create a rotation with only yaw
129
+ # The rotation property is a 3x3 numpy array
130
+ waist_yaw = np.arctan2(waist_orientation[1, 0], waist_orientation[0, 0])
131
+ # Create a rotation matrix with only yaw using Pinocchio's rpy functions
132
+ waist_yaw_only_rotation = rpy.rpyToMatrix(0, 0, waist_yaw)
133
+ yaw_only_waist_from_torso = waist_yaw_only_rotation.T @ torso_orientation
134
+ torso_orientation_rpy = rpy.matrixToRpy(yaw_only_waist_from_torso)
135
+
136
+ lower_body_action = self.lower_body_policy.get_action(
137
+ time, q_arms, base_height_command, torso_orientation_rpy, interpolated_navigate_cmd
138
+ )
139
+
140
+ # If pelvis is both in upper and lower body, lower body policy takes preference
141
+ q[lower_body_indices] = lower_body_action["body_action"][0][
142
+ : len(lower_body_indices)
143
+ ] # lower body (legs + waist)
144
+
145
+ self.last_action = {"q": q}
146
+
147
+ return {"q": q}
148
+
149
+ def handle_keyboard_button(self, key):
150
+ try:
151
+ self.lower_body_policy.locomotion_policy.handle_keyboard_button(key)
152
+ except AttributeError:
153
+ # Only catch AttributeError, let other exceptions propagate
154
+ self.lower_body_policy.handle_keyboard_button(key)
155
+
156
+ def activate_policy(self):
157
+ self.handle_keyboard_button("]")
GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_gear_wbc_policy.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import collections
2
+ from pathlib import Path
3
+ from typing import Any, Dict, Optional
4
+
5
+ import numpy as np
6
+ import onnxruntime as ort
7
+ import torch
8
+
9
+ from decoupled_wbc.control.base.policy import Policy
10
+ from decoupled_wbc.control.utils.gear_wbc_utils import get_gravity_orientation, load_config
11
+
12
+
13
+ class G1GearWbcPolicy(Policy):
14
+ """Simple G1 robot policy using OpenGearWbc trained neural network."""
15
+
16
+ def __init__(self, robot_model, config: str, model_path: str):
17
+ """Initialize G1GearWbcPolicy.
18
+
19
+ Args:
20
+ config_path: Path to gear_wbc YAML configuration file
21
+ """
22
+ self.config, self.LEGGED_GYM_ROOT_DIR = load_config(config)
23
+ self.robot_model = robot_model
24
+ self.use_teleop_policy_cmd = False
25
+
26
+ package_root = Path(__file__).resolve().parents[2]
27
+ self.sim2mujoco_root_dir = str(package_root / "sim2mujoco")
28
+ model_path_1, model_path_2 = model_path.split(",")
29
+
30
+ self.policy_1 = self.load_onnx_policy(
31
+ self.sim2mujoco_root_dir + "/resources/robots/g1/" + model_path_1
32
+ )
33
+ self.policy_2 = self.load_onnx_policy(
34
+ self.sim2mujoco_root_dir + "/resources/robots/g1/" + model_path_2
35
+ )
36
+
37
+ # Initialize observation history buffer
38
+ self.observation = None
39
+ self.obs_history = collections.deque(maxlen=self.config["obs_history_len"])
40
+ self.obs_buffer = np.zeros(self.config["num_obs"], dtype=np.float32)
41
+ self.counter = 0
42
+
43
+ # Initialize state variables
44
+ self.use_policy_action = False
45
+ self.action = np.zeros(self.config["num_actions"], dtype=np.float32)
46
+ self.target_dof_pos = self.config["default_angles"].copy()
47
+ self.cmd = self.config["cmd_init"].copy()
48
+ self.height_cmd = self.config["height_cmd"]
49
+ self.freq_cmd = self.config["freq_cmd"]
50
+ self.roll_cmd = self.config["rpy_cmd"][0]
51
+ self.pitch_cmd = self.config["rpy_cmd"][1]
52
+ self.yaw_cmd = self.config["rpy_cmd"][2]
53
+ self.gait_indices = torch.zeros((1), dtype=torch.float32)
54
+
55
+ def load_onnx_policy(self, model_path: str):
56
+ print(f"Loading ONNX policy from {model_path}")
57
+ model = ort.InferenceSession(model_path)
58
+
59
+ def run_inference(input_tensor):
60
+ ort_inputs = {model.get_inputs()[0].name: input_tensor.cpu().numpy()}
61
+ ort_outs = model.run(None, ort_inputs)
62
+ return torch.tensor(ort_outs[0], device="cpu")
63
+
64
+ print(f"Successfully loaded ONNX policy from {model_path}")
65
+
66
+ return run_inference
67
+
68
+ def compute_observation(self, observation: Dict[str, Any]) -> tuple[np.ndarray, int]:
69
+ """Compute the observation vector from current state"""
70
+ # Get body joint indices (excluding waist roll and pitch)
71
+ self.gait_indices = torch.remainder(self.gait_indices + 0.02 * self.freq_cmd, 1.0)
72
+ durations = torch.full_like(self.gait_indices, 0.5)
73
+ phases = 0.5
74
+ foot_indices = [
75
+ self.gait_indices + phases, # FL
76
+ self.gait_indices, # FR
77
+ ]
78
+ self.foot_indices = torch.remainder(
79
+ torch.cat([foot_indices[i].unsqueeze(1) for i in range(2)], dim=1), 1.0
80
+ )
81
+ for fi in foot_indices:
82
+ stance = fi < durations
83
+ swing = fi >= durations
84
+ fi[stance] = fi[stance] * (0.5 / durations[stance])
85
+ fi[swing] = 0.5 + (fi[swing] - durations[swing]) * (0.5 / (1 - durations[swing]))
86
+
87
+ self.clock_inputs = torch.stack([torch.sin(2 * np.pi * fi) for fi in foot_indices], dim=1)
88
+
89
+ body_indices = self.robot_model.get_joint_group_indices("body")
90
+ body_indices = [idx for idx in body_indices]
91
+
92
+ n_joints = len(body_indices)
93
+
94
+ # Extract joint data
95
+ qj = observation["q"][body_indices].copy()
96
+ dqj = observation["dq"][body_indices].copy()
97
+
98
+ # Extract floating base data
99
+ quat = observation["floating_base_pose"][3:7].copy() # quaternion
100
+ omega = observation["floating_base_vel"][3:6].copy() # angular velocity
101
+
102
+ # Handle default angles padding
103
+ if len(self.config["default_angles"]) < n_joints:
104
+ padded_defaults = np.zeros(n_joints, dtype=np.float32)
105
+ padded_defaults[: len(self.config["default_angles"])] = self.config["default_angles"]
106
+ else:
107
+ padded_defaults = self.config["default_angles"][:n_joints]
108
+
109
+ # Scale the values
110
+ qj_scaled = (qj - padded_defaults) * self.config["dof_pos_scale"]
111
+ dqj_scaled = dqj * self.config["dof_vel_scale"]
112
+ gravity_orientation = get_gravity_orientation(quat)
113
+ omega_scaled = omega * self.config["ang_vel_scale"]
114
+
115
+ # Calculate single observation dimension
116
+ single_obs_dim = 86 # 3 + 1 + 3 + 3 + 3 + n_joints + n_joints + 15, n_joints = 29
117
+
118
+ # Create single observation
119
+ single_obs = np.zeros(single_obs_dim, dtype=np.float32)
120
+ single_obs[0:3] = self.cmd[:3] * self.config["cmd_scale"]
121
+ single_obs[3:4] = np.array([self.height_cmd])
122
+ single_obs[4:7] = np.array([self.roll_cmd, self.pitch_cmd, self.yaw_cmd])
123
+ single_obs[7:10] = omega_scaled
124
+ single_obs[10:13] = gravity_orientation
125
+ # single_obs[14:17] = omega_scaled_torso
126
+ # single_obs[17:20] = gravity_torso
127
+ single_obs[13 : 13 + n_joints] = qj_scaled
128
+ single_obs[13 + n_joints : 13 + 2 * n_joints] = dqj_scaled
129
+ single_obs[13 + 2 * n_joints : 13 + 2 * n_joints + 15] = self.action
130
+ # single_obs[13 + 2 * n_joints + 15 : 13 + 2 * n_joints + 15 + 2] = (
131
+ # processed_clock_inputs.detach().cpu().numpy()
132
+ # )
133
+ return single_obs, single_obs_dim
134
+
135
+ def set_observation(self, observation: Dict[str, Any]):
136
+ """Update the policy's current observation of the environment.
137
+
138
+ Args:
139
+ observation: Dictionary containing single observation from current state
140
+ Should include 'obs' key with current single observation
141
+ """
142
+
143
+ # Extract the single observation
144
+ self.observation = observation
145
+ single_obs, single_obs_dim = self.compute_observation(observation)
146
+
147
+ # Update observation history every control_decimation steps
148
+ # if self.counter % self.config['control_decimation'] == 0:
149
+ # Add current observation to history
150
+ self.obs_history.append(single_obs)
151
+
152
+ # Fill history with zeros if not enough observations yet
153
+ while len(self.obs_history) < self.config["obs_history_len"]:
154
+ self.obs_history.appendleft(np.zeros_like(single_obs))
155
+
156
+ # Construct full observation with history
157
+ single_obs_dim = len(single_obs)
158
+ for i, hist_obs in enumerate(self.obs_history):
159
+ start_idx = i * single_obs_dim
160
+ end_idx = start_idx + single_obs_dim
161
+ self.obs_buffer[start_idx:end_idx] = hist_obs
162
+
163
+ # Convert to tensor for policy
164
+ self.obs_tensor = torch.from_numpy(self.obs_buffer).unsqueeze(0)
165
+ # self.counter += 1
166
+
167
+ assert self.obs_tensor.shape[1] == self.config["num_obs"]
168
+
169
+ def set_use_teleop_policy_cmd(self, use_teleop_policy_cmd: bool):
170
+ self.use_teleop_policy_cmd = use_teleop_policy_cmd
171
+ # Safety: When teleop is disabled, reset navigation to stop
172
+ if not use_teleop_policy_cmd:
173
+ self.nav_cmd = self.config["cmd_init"].copy() # Reset to safe default
174
+
175
+ def set_goal(self, goal: Dict[str, Any]):
176
+ """Set the goal for the policy.
177
+
178
+ Args:
179
+ goal: Dictionary containing the goal for the policy
180
+ """
181
+
182
+ if "toggle_policy_action" in goal:
183
+ if goal["toggle_policy_action"]:
184
+ self.use_policy_action = not self.use_policy_action
185
+
186
+ def get_action(
187
+ self,
188
+ time: Optional[float] = None,
189
+ arms_target_pose: Optional[np.ndarray] = None,
190
+ base_height_command: Optional[np.ndarray] = None,
191
+ torso_orientation_rpy: Optional[np.ndarray] = None,
192
+ interpolated_navigate_cmd: Optional[np.ndarray] = None,
193
+ ) -> Dict[str, Any]:
194
+ """Compute and return the next action based on current observation.
195
+
196
+ Args:
197
+ time: Optional "monotonic time" for time-dependent policies (unused)
198
+
199
+ Returns:
200
+ Dictionary containing the action to be executed
201
+ """
202
+ if self.obs_tensor is None:
203
+ raise ValueError("No observation set. Call set_observation() first.")
204
+
205
+ if base_height_command is not None and self.use_teleop_policy_cmd:
206
+ self.height_cmd = (
207
+ base_height_command[0]
208
+ if isinstance(base_height_command, list)
209
+ else base_height_command
210
+ )
211
+
212
+ if interpolated_navigate_cmd is not None and self.use_teleop_policy_cmd:
213
+ self.cmd = interpolated_navigate_cmd
214
+
215
+ if torso_orientation_rpy is not None and self.use_teleop_policy_cmd:
216
+ self.roll_cmd = torso_orientation_rpy[0]
217
+ self.pitch_cmd = torso_orientation_rpy[1]
218
+ self.yaw_cmd = torso_orientation_rpy[2]
219
+
220
+ # Run policy inference
221
+ with torch.no_grad():
222
+ # Select appropriate policy based on command magnitude
223
+ if np.linalg.norm(self.cmd) < 0.05:
224
+ # Use standing policy for small commands
225
+ policy = self.policy_1
226
+ else:
227
+ # Use walking policy for movement commands
228
+ policy = self.policy_2
229
+
230
+ self.action = policy(self.obs_tensor).detach().numpy().squeeze()
231
+
232
+ # Transform action to target_dof_pos
233
+ if self.use_policy_action:
234
+ cmd_q = self.action * self.config["action_scale"] + self.config["default_angles"]
235
+ else:
236
+ cmd_q = self.observation["q"][self.robot_model.get_joint_group_indices("lower_body")]
237
+
238
+ cmd_dq = np.zeros(self.config["num_actions"])
239
+ cmd_tau = np.zeros(self.config["num_actions"])
240
+
241
+ return {"body_action": (cmd_q, cmd_dq, cmd_tau)}
242
+
243
+ def handle_keyboard_button(self, key):
244
+ if key == "]":
245
+ self.use_policy_action = True
246
+ elif key == "o":
247
+ self.use_policy_action = False
248
+ elif key == "w":
249
+ self.cmd[0] += 0.2
250
+ elif key == "s":
251
+ self.cmd[0] -= 0.2
252
+ elif key == "a":
253
+ self.cmd[1] += 0.2
254
+ elif key == "d":
255
+ self.cmd[1] -= 0.2
256
+ elif key == "q":
257
+ self.cmd[2] += 0.2
258
+ elif key == "e":
259
+ self.cmd[2] -= 0.2
260
+ elif key == "z":
261
+ self.cmd[0] = 0.0
262
+ self.cmd[1] = 0.0
263
+ self.cmd[2] = 0.0
264
+ elif key == "1":
265
+ self.height_cmd += 0.1
266
+ elif key == "2":
267
+ self.height_cmd -= 0.1
268
+ elif key == "n":
269
+ self.freq_cmd -= 0.1
270
+ self.freq_cmd = max(1.0, self.freq_cmd)
271
+ elif key == "m":
272
+ self.freq_cmd += 0.1
273
+ self.freq_cmd = min(2.0, self.freq_cmd)
274
+ elif key == "3":
275
+ self.roll_cmd -= np.deg2rad(10)
276
+ elif key == "4":
277
+ self.roll_cmd += np.deg2rad(10)
278
+ elif key == "5":
279
+ self.pitch_cmd -= np.deg2rad(10)
280
+ elif key == "6":
281
+ self.pitch_cmd += np.deg2rad(10)
282
+ elif key == "7":
283
+ self.yaw_cmd -= np.deg2rad(10)
284
+ elif key == "8":
285
+ self.yaw_cmd += np.deg2rad(10)
286
+
287
+ if key:
288
+ print("--------------------------------")
289
+ print(f"Linear velocity command: {self.cmd}")
290
+ print(f"Base height command: {self.height_cmd}")
291
+ print(f"Use policy action: {self.use_policy_action}")
292
+ print(f"roll deg angle: {np.rad2deg(self.roll_cmd)}")
293
+ print(f"pitch deg angle: {np.rad2deg(self.pitch_cmd)}")
294
+ print(f"yaw deg angle: {np.rad2deg(self.yaw_cmd)}")
295
+ print(f"Gait frequency: {self.freq_cmd}")
GR00T-WholeBodyControl/decoupled_wbc/control/policy/identity_policy.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from copy import deepcopy
2
+ from typing import Optional
3
+
4
+ import gymnasium as gym
5
+
6
+ from decoupled_wbc.control.base.policy import Policy
7
+
8
+
9
+ class IdentityPolicy(Policy):
10
+ def __init__(self):
11
+ self.reset()
12
+
13
+ def get_action(self, time: Optional[float] = None) -> dict[str, any]:
14
+ return self.goal
15
+
16
+ def set_goal(self, goal: dict[str, any]) -> None:
17
+ self.goal = deepcopy(goal)
18
+ self.goal.pop("interpolation_garbage_collection_time", None)
19
+ self.goal.pop("target_time", None)
20
+
21
+ def observation_space(self) -> gym.spaces.Dict:
22
+ return gym.spaces.Dict()
23
+
24
+ def action_space(self) -> gym.spaces.Dict:
25
+ return gym.spaces.Dict()
GR00T-WholeBodyControl/decoupled_wbc/control/policy/interpolation_policy.py ADDED
@@ -0,0 +1,297 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import numbers
2
+ import time as time_module
3
+ from typing import Any, Dict, Optional, Union
4
+
5
+ import gymnasium as gym
6
+ import numpy as np
7
+ import scipy.interpolate as si
8
+
9
+ from decoupled_wbc.control.base.policy import Policy
10
+
11
+
12
+ class InterpolationPolicy(Policy):
13
+ def __init__(
14
+ self,
15
+ init_time: float,
16
+ init_values: dict[str, np.ndarray],
17
+ max_change_rate: float,
18
+ ):
19
+ """
20
+ Args:
21
+ init_time: The time of recording the initial values.
22
+ init_values: The initial values of the features.
23
+ The keys are the names of the features, and the values
24
+ are the initial values of the features (1D array).
25
+ max_change_rate: The maximum change rate.
26
+ """
27
+ super().__init__()
28
+ self.last_action = init_values # Vecs are 1D arrays
29
+ self.concat_order = sorted(init_values.keys())
30
+ self.concat_dims = []
31
+ for key in self.concat_order:
32
+ vec = np.array(init_values[key])
33
+ if vec.ndim == 2 and vec.shape[0] == 1:
34
+ vec = vec[0]
35
+ init_values[key] = vec
36
+ assert vec.ndim == 1, f"The shape of {key} should be (D,). Got {vec.shape}."
37
+ self.concat_dims.append(vec.shape[0])
38
+
39
+ self.init_values_concat = self._concat_vecs(init_values, 1)
40
+ self.max_change_rate = max_change_rate
41
+ self.reset(init_time)
42
+
43
+ def reset(self, init_time: float = time_module.monotonic()):
44
+ self.interp = PoseTrajectoryInterpolator(np.array([init_time]), self.init_values_concat)
45
+ self.last_waypoint_time = init_time
46
+ self.max_change_rate = self.max_change_rate
47
+
48
+ def _concat_vecs(self, values: dict[str, np.ndarray], length: int) -> np.ndarray:
49
+ """
50
+ Concatenate the vectors into a 2D array to be used for interpolation.
51
+ Args:
52
+ values: The values to concatenate.
53
+ length: The length of the concatenated vectors (time dimension).
54
+ Returns:
55
+ The concatenated vectors (T, D) arrays.
56
+ """
57
+ concat_vecs = []
58
+ for key in self.concat_order:
59
+ if key in values:
60
+ vec = np.array(values[key])
61
+ if vec.ndim == 1:
62
+ # If the vector is 1D, tile it to the length of the time dimension
63
+ vec = np.tile(vec, (length, 1))
64
+ assert vec.ndim == 2, f"The shape of {key} should be (T, D). Got {vec.shape}."
65
+ concat_vecs.append(vec)
66
+ else:
67
+ # If the vector is not in the values, use the last action
68
+ # Since the last action is 1D, we need to tile it to the length of the time dimension
69
+ concat_vecs.append(np.tile(self.last_action[key], (length, 1)))
70
+ return np.concatenate(concat_vecs, axis=1) # Vecs are 2D (T, D) arrays
71
+
72
+ def _unconcat_vecs(self, concat_vec: np.ndarray) -> dict[str, np.ndarray]:
73
+ curr_idx = 0
74
+ action = {}
75
+ assert (
76
+ concat_vec.ndim == 1
77
+ ), f"The shape of the concatenated vectors should be (T, D). Got {concat_vec.shape}."
78
+ for key, dim in zip(self.concat_order, self.concat_dims):
79
+ action[key] = concat_vec[curr_idx : curr_idx + dim]
80
+ curr_idx += dim
81
+ return action # Vecs are 1D arrays
82
+
83
+ def __call__(
84
+ self, observation: Dict[str, Any], goal: Dict[str, Any], time: float
85
+ ) -> Dict[str, np.ndarray]:
86
+ raise NotImplementedError(
87
+ "`InterpolationPolicy` accepts goal and provide action in two separate methods."
88
+ )
89
+
90
+ def set_goal(self, goal: Dict[str, Any]) -> None:
91
+ if "target_time" not in goal:
92
+ return
93
+ assert (
94
+ "interpolation_garbage_collection_time" in goal
95
+ ), "`interpolation_garbage_collection_time` is required."
96
+ target_time = goal.pop("target_time")
97
+ interpolation_garbage_collection_time = goal.pop("interpolation_garbage_collection_time")
98
+
99
+ if isinstance(target_time, list):
100
+ for key, vec in goal.items():
101
+ assert isinstance(vec, list)
102
+ assert len(vec) == len(target_time), (
103
+ f"The length of {key} and `target_time` should be the same. "
104
+ f"Got {len(vec)} and {len(target_time)}."
105
+ )
106
+ else:
107
+ target_time = [target_time]
108
+ for key in goal:
109
+ goal[key] = [goal[key]]
110
+
111
+ # Concatenate all vectors in goal
112
+ concat_vecs = self._concat_vecs(goal, len(target_time))
113
+ assert concat_vecs.shape[0] == len(target_time), (
114
+ f"The length of the concatenated goal and `target_time` should be the same. "
115
+ f"Got {concat_vecs.shape[0]} and {len(target_time)}."
116
+ )
117
+
118
+ for tt, vec in zip(target_time, concat_vecs):
119
+ if tt < interpolation_garbage_collection_time:
120
+ continue
121
+ self.interp = self.interp.schedule_waypoint(
122
+ pose=vec,
123
+ time=tt,
124
+ max_change_rate=self.max_change_rate,
125
+ interpolation_garbage_collection_time=interpolation_garbage_collection_time,
126
+ last_waypoint_time=self.last_waypoint_time,
127
+ )
128
+ self.last_waypoint_time = tt
129
+
130
+ def get_action(self, time: Optional[float] = None) -> dict[str, Any]:
131
+ """Get the next action based on the (current) monotonic time."""
132
+ if time is None:
133
+ time = time_module.monotonic()
134
+ concat_vec = self.interp(time)
135
+ self.last_action.update(self._unconcat_vecs(concat_vec))
136
+ return self.last_action
137
+
138
+ def observation_space(self) -> gym.spaces.Dict:
139
+ """Return the observation space."""
140
+ pass
141
+
142
+ def action_space(self) -> gym.spaces.Dict:
143
+ """Return the action space."""
144
+ pass
145
+
146
+ def close(self) -> None:
147
+ """Clean up resources."""
148
+ pass
149
+
150
+
151
+ class PoseTrajectoryInterpolator:
152
+ def __init__(self, times: np.ndarray, poses: np.ndarray):
153
+ assert len(times) >= 1
154
+ assert len(poses) == len(times)
155
+
156
+ times = np.asarray(times)
157
+ poses = np.asarray(poses)
158
+
159
+ self.num_joint = len(poses[0])
160
+
161
+ if len(times) == 1:
162
+ # special treatment for single step interpolation
163
+ self.single_step = True
164
+ self._times = times
165
+ self._poses = poses
166
+ else:
167
+ self.single_step = False
168
+ assert np.all(times[1:] >= times[:-1])
169
+ self.pose_interp = si.interp1d(times, poses, axis=0, assume_sorted=True)
170
+
171
+ @property
172
+ def times(self) -> np.ndarray:
173
+ if self.single_step:
174
+ return self._times
175
+ else:
176
+ return self.pose_interp.x
177
+
178
+ @property
179
+ def poses(self) -> np.ndarray:
180
+ if self.single_step:
181
+ return self._poses
182
+ else:
183
+ return self.pose_interp.y
184
+
185
+ def trim(self, start_t: float, end_t: float) -> "PoseTrajectoryInterpolator":
186
+ assert start_t <= end_t
187
+ times = self.times
188
+ should_keep = (start_t < times) & (times < end_t)
189
+ keep_times = times[should_keep]
190
+ all_times = np.concatenate([[start_t], keep_times, [end_t]])
191
+ # remove duplicates, Slerp requires strictly increasing x
192
+ all_times = np.unique(all_times)
193
+ # interpolate
194
+ all_poses = self(all_times)
195
+ return PoseTrajectoryInterpolator(times=all_times, poses=all_poses)
196
+
197
+ def schedule_waypoint(
198
+ self,
199
+ pose,
200
+ time,
201
+ max_change_rate=np.inf,
202
+ interpolation_garbage_collection_time=None,
203
+ last_waypoint_time=None,
204
+ ) -> "PoseTrajectoryInterpolator":
205
+ if not isinstance(max_change_rate, np.ndarray):
206
+ max_change_rate = np.array([max_change_rate] * self.num_joint)
207
+
208
+ assert len(max_change_rate) == self.num_joint
209
+ assert np.max(max_change_rate) > 0
210
+
211
+ if last_waypoint_time is not None:
212
+ assert interpolation_garbage_collection_time is not None
213
+
214
+ # trim current interpolator to between interpolation_garbage_collection_time and last_waypoint_time
215
+ start_time = self.times[0]
216
+ end_time = self.times[-1]
217
+ assert start_time <= end_time
218
+ if interpolation_garbage_collection_time is not None:
219
+ if time <= interpolation_garbage_collection_time:
220
+ # if insert time is earlier than current time
221
+ # no effect should be done to the interpolator
222
+ return self
223
+ # now, interpolation_garbage_collection_time < time
224
+ start_time = max(interpolation_garbage_collection_time, start_time)
225
+
226
+ if last_waypoint_time is not None:
227
+ # if last_waypoint_time is earlier than start_time
228
+ # use start_time
229
+ if time <= last_waypoint_time:
230
+ end_time = interpolation_garbage_collection_time
231
+ else:
232
+ end_time = max(last_waypoint_time, interpolation_garbage_collection_time)
233
+ else:
234
+ end_time = interpolation_garbage_collection_time
235
+
236
+ end_time = min(end_time, time)
237
+ start_time = min(start_time, end_time)
238
+ # end time should be the latest of all times except time
239
+ # after this we can assume order (proven by zhenjia, due to the 2 min operations)
240
+
241
+ # Constraints:
242
+ # start_time <= end_time <= time (proven by zhenjia)
243
+ # interpolation_garbage_collection_time <= start_time (proven by zhenjia)
244
+ # interpolation_garbage_collection_time <= time (proven by zhenjia)
245
+
246
+ # time can't change
247
+ # last_waypoint_time can't change
248
+ # interpolation_garbage_collection_time can't change
249
+ assert start_time <= end_time
250
+ assert end_time <= time
251
+ if last_waypoint_time is not None:
252
+ if time <= last_waypoint_time:
253
+ assert end_time == interpolation_garbage_collection_time
254
+ else:
255
+ assert end_time == max(last_waypoint_time, interpolation_garbage_collection_time)
256
+
257
+ if interpolation_garbage_collection_time is not None:
258
+ assert interpolation_garbage_collection_time <= start_time
259
+ assert interpolation_garbage_collection_time <= time
260
+ trimmed_interp = self.trim(start_time, end_time)
261
+ # after this, all waypoints in trimmed_interp is within start_time and end_time
262
+ # and is earlier than time
263
+
264
+ # determine speed
265
+ duration = time - end_time
266
+ end_pose = trimmed_interp(end_time)
267
+ pose_min_duration = np.max(np.abs(end_pose - pose) / max_change_rate)
268
+ duration = max(duration, pose_min_duration)
269
+ assert duration >= 0
270
+ last_waypoint_time = end_time + duration
271
+
272
+ # insert new pose
273
+ times = np.append(trimmed_interp.times, [last_waypoint_time], axis=0)
274
+ poses = np.append(trimmed_interp.poses, [pose], axis=0)
275
+
276
+ # create new interpolator
277
+ final_interp = PoseTrajectoryInterpolator(times, poses)
278
+ return final_interp
279
+
280
+ def __call__(self, t: Union[numbers.Number, np.ndarray]) -> np.ndarray:
281
+ is_single = False
282
+ if isinstance(t, numbers.Number):
283
+ is_single = True
284
+ t = np.array([t])
285
+
286
+ pose = np.zeros((len(t), self.num_joint))
287
+ if self.single_step:
288
+ pose[:] = self._poses[0]
289
+ else:
290
+ start_time = self.times[0]
291
+ end_time = self.times[-1]
292
+ t = np.clip(t, start_time, end_time)
293
+ pose = self.pose_interp(t)
294
+
295
+ if is_single:
296
+ pose = pose[0]
297
+ return pose
GR00T-WholeBodyControl/decoupled_wbc/control/policy/keyboard_navigation_policy.py ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import Any, Dict, Optional
2
+
3
+ import numpy as np
4
+
5
+ from decoupled_wbc.control.base.policy import Policy
6
+
7
+
8
+ class KeyboardNavigationPolicy(Policy):
9
+ def __init__(
10
+ self,
11
+ max_linear_velocity: float = 0.5,
12
+ max_angular_velocity: float = 0.5,
13
+ verbose: bool = True,
14
+ **kwargs,
15
+ ):
16
+ """
17
+ Initialize the navigation policy.
18
+
19
+ Args:
20
+ max_linear_velocity: Maximum linear velocity in m/s (for x and y components)
21
+ max_angular_velocity: Maximum angular velocity in rad/s (for yaw component)
22
+ **kwargs: Additional arguments passed to the base Policy class
23
+ """
24
+ super().__init__(**kwargs)
25
+ self.max_linear_velocity = max_linear_velocity
26
+ self.max_angular_velocity = max_angular_velocity
27
+ self.verbose = verbose
28
+
29
+ # Initialize velocity commands
30
+ self.lin_vel_command = np.zeros(2, dtype=np.float32) # [vx, vy]
31
+ self.ang_vel_command = np.zeros(1, dtype=np.float32) # [wz]
32
+
33
+ def get_action(self, time: Optional[float] = None) -> Dict[str, Any]:
34
+ """
35
+ Get the action to execute based on current state.
36
+
37
+ Args:
38
+ time: Current time (optional)
39
+
40
+ Returns:
41
+ Dict containing the action to execute with:
42
+ - navigate_cmd: np.array([vx, vy, wz]) where:
43
+ - vx: linear velocity in x direction (m/s)
44
+ - vy: linear velocity in y direction (m/s)
45
+ - wz: angular velocity around z axis (rad/s)
46
+ """
47
+ # Combine linear and angular velocities into a single command
48
+ # Ensure velocities are within limits
49
+ vx = np.clip(self.lin_vel_command[0], -self.max_linear_velocity, self.max_linear_velocity)
50
+ vy = np.clip(self.lin_vel_command[1], -self.max_linear_velocity, self.max_linear_velocity)
51
+ wz = np.clip(self.ang_vel_command[0], -self.max_angular_velocity, self.max_angular_velocity)
52
+
53
+ navigate_cmd = np.array([vx, vy, wz], dtype=np.float32)
54
+
55
+ action = {"navigate_cmd": navigate_cmd}
56
+ return action
57
+
58
+ def handle_keyboard_button(self, keycode: str):
59
+ """
60
+ Handle keyboard inputs for navigation control.
61
+
62
+ Args:
63
+ keycode: The key that was pressed
64
+ """
65
+ if keycode == "w":
66
+ self.lin_vel_command[0] += 0.1 # Increase forward velocity
67
+ elif keycode == "s":
68
+ self.lin_vel_command[0] -= 0.1 # Increase backward velocity
69
+ elif keycode == "a":
70
+ self.lin_vel_command[1] += 0.1 # Increase left velocity
71
+ elif keycode == "d":
72
+ self.lin_vel_command[1] -= 0.1 # Increase right velocity
73
+ elif keycode == "q":
74
+ self.ang_vel_command[0] += 0.1 # Increase counter-clockwise rotation
75
+ elif keycode == "e":
76
+ self.ang_vel_command[0] -= 0.1 # Increase clockwise rotation
77
+ elif keycode == "z":
78
+ # Reset all velocities
79
+ self.lin_vel_command[:] = 0.0
80
+ self.ang_vel_command[:] = 0.0
81
+ if self.verbose:
82
+ print("Navigation policy: Reset all velocity commands to zero")
83
+
84
+ # Print current velocities after any keyboard input
85
+ if self.verbose:
86
+ print(f"Nav lin vel: ({self.lin_vel_command[0]:.2f}, {self.lin_vel_command[1]:.2f})")
87
+ print(f"Nav ang vel: {self.ang_vel_command[0]:.2f}")
GR00T-WholeBodyControl/decoupled_wbc/control/policy/lerobot_replay_policy.py ADDED
@@ -0,0 +1,111 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import pandas as pd
4
+
5
+ from decoupled_wbc.control.base.policy import Policy
6
+ from decoupled_wbc.control.main.constants import (
7
+ DEFAULT_BASE_HEIGHT,
8
+ DEFAULT_NAV_CMD,
9
+ DEFAULT_WRIST_POSE,
10
+ )
11
+ from decoupled_wbc.control.robot_model.robot_model import RobotModel
12
+ from decoupled_wbc.data.viz.rerun_viz import RerunViz
13
+
14
+
15
+ class LerobotReplayPolicy(Policy):
16
+ """Replay policy for Lerobot dataset, so we can replay the dataset
17
+ and just use the action from the dataset.
18
+
19
+ Args:
20
+ parquet_path: Path to the parquet file containing the dataset.
21
+ """
22
+
23
+ is_active = True # by default, the replay policy is active
24
+
25
+ def __init__(self, robot_model: RobotModel, parquet_path: str, use_viz: bool = False):
26
+ # self.dataset = LerobotDataset(dataset_path)
27
+ self.parquet_path = parquet_path
28
+ self._ctr = 0
29
+ # read the parquet file
30
+ self.df = pd.read_parquet(self.parquet_path)
31
+ self._max_ctr = len(self.df)
32
+ # get the action from the dataframe
33
+ self.action = self.df.iloc[self._ctr]["action"]
34
+ self.use_viz = use_viz
35
+ if self.use_viz:
36
+ self.viz = RerunViz(
37
+ image_keys=["egoview_image"],
38
+ tensor_keys=[
39
+ "left_arm_qpos",
40
+ "left_hand_qpos",
41
+ "right_arm_qpos",
42
+ "right_hand_qpos",
43
+ ],
44
+ window_size=5.0,
45
+ )
46
+ self.robot_model = robot_model
47
+ self.upper_body_joint_indices = self.robot_model.get_joint_group_indices("upper_body")
48
+
49
+ def get_action(self) -> dict[str, any]:
50
+ # get the action from the dataframe
51
+ action = self.df.iloc[self._ctr]["action"]
52
+ wrist_pose = self.df.iloc[self._ctr]["action.eef"]
53
+ navigate_cmd = self.df.iloc[self._ctr].get("teleop.navigate_command", DEFAULT_NAV_CMD)
54
+ base_height_cmd = self.df.iloc[self._ctr].get(
55
+ "teleop.base_height_command", DEFAULT_BASE_HEIGHT
56
+ )
57
+
58
+ self._ctr += 1
59
+ if self._ctr >= self._max_ctr:
60
+ self._ctr = 0
61
+ # print(f"Replay {self._ctr} / {self._max_ctr}")
62
+ if self.use_viz:
63
+ self.viz.plot_tensors(
64
+ {
65
+ "left_arm_qpos": action[self.robot_model.get_joint_group_indices("left_arm")]
66
+ + 15,
67
+ "left_hand_qpos": action[self.robot_model.get_joint_group_indices("left_hand")]
68
+ + 15,
69
+ "right_arm_qpos": action[self.robot_model.get_joint_group_indices("right_arm")]
70
+ + 15,
71
+ "right_hand_qpos": action[
72
+ self.robot_model.get_joint_group_indices("right_hand")
73
+ ]
74
+ + 15,
75
+ },
76
+ time.monotonic(),
77
+ )
78
+
79
+ return {
80
+ "target_upper_body_pose": action[self.upper_body_joint_indices],
81
+ "wrist_pose": wrist_pose,
82
+ "navigate_cmd": navigate_cmd,
83
+ "base_height_cmd": base_height_cmd,
84
+ "timestamp": time.time(),
85
+ }
86
+
87
+ def action_to_cmd(self, action: dict[str, any]) -> dict[str, any]:
88
+ action["target_upper_body_pose"] = action["q"][
89
+ self.robot_model.get_joint_group_indices("upper_body")
90
+ ]
91
+ del action["q"]
92
+ return action
93
+
94
+ def set_observation(self, observation: dict[str, any]):
95
+ pass
96
+
97
+ def get_observation(self) -> dict[str, any]:
98
+ return {
99
+ "wrist_pose": self.df.iloc[self._ctr - 1].get(
100
+ "observation.eef_state", DEFAULT_WRIST_POSE
101
+ ),
102
+ "timestamp": time.time(),
103
+ }
104
+
105
+
106
+ if __name__ == "__main__":
107
+ policy = LerobotReplayPolicy(
108
+ parquet_path="outputs/g1-open-hands-may7/data/chunk-000/episode_000000.parquet"
109
+ )
110
+ action = policy.get_action()
111
+ print(action)
GR00T-WholeBodyControl/decoupled_wbc/control/policy/teleop_policy.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import contextmanager
2
+ import time
3
+ from typing import Optional
4
+
5
+ import numpy as np
6
+ from scipy.spatial.transform import Rotation as R
7
+
8
+ from decoupled_wbc.control.base.policy import Policy
9
+ from decoupled_wbc.control.robot_model import RobotModel
10
+ from decoupled_wbc.control.teleop.teleop_retargeting_ik import TeleopRetargetingIK
11
+ from decoupled_wbc.control.teleop.teleop_streamer import TeleopStreamer
12
+
13
+
14
+ class TeleopPolicy(Policy):
15
+ """
16
+ Robot-agnostic teleop policy.
17
+ Clean separation: IK processing vs command passing.
18
+ All robot-specific properties are abstracted through robot_model and hand_ik_solvers.
19
+ """
20
+
21
+ def __init__(
22
+ self,
23
+ body_control_device: str,
24
+ hand_control_device: str,
25
+ robot_model: RobotModel,
26
+ retargeting_ik: TeleopRetargetingIK,
27
+ body_streamer_ip: str = "192.168.?.?",
28
+ body_streamer_keyword: str = "shoulder",
29
+ enable_real_device: bool = True,
30
+ replay_data_path: Optional[str] = None,
31
+ replay_speed: float = 1.0,
32
+ wait_for_activation: int = 5,
33
+ activate_keyboard_listener: bool = True,
34
+ ):
35
+ if activate_keyboard_listener:
36
+ from decoupled_wbc.control.utils.keyboard_dispatcher import KeyboardListenerSubscriber
37
+
38
+ self.keyboard_listener = KeyboardListenerSubscriber()
39
+ else:
40
+ self.keyboard_listener = None
41
+
42
+ self.wait_for_activation = wait_for_activation
43
+
44
+ self.teleop_streamer = TeleopStreamer(
45
+ robot_model=robot_model,
46
+ body_control_device=body_control_device,
47
+ hand_control_device=hand_control_device,
48
+ enable_real_device=enable_real_device,
49
+ body_streamer_ip=body_streamer_ip,
50
+ body_streamer_keyword=body_streamer_keyword,
51
+ replay_data_path=replay_data_path,
52
+ replay_speed=replay_speed,
53
+ )
54
+ self.robot_model = robot_model
55
+ self.retargeting_ik = retargeting_ik
56
+ self.is_active = False
57
+
58
+ self.latest_left_wrist_data = np.eye(4)
59
+ self.latest_right_wrist_data = np.eye(4)
60
+ self.latest_left_fingers_data = {"position": np.zeros((25, 4, 4))}
61
+ self.latest_right_fingers_data = {"position": np.zeros((25, 4, 4))}
62
+
63
+ def set_goal(self, goal: dict[str, any]):
64
+ # The current teleop policy doesn't take higher level commands yet.
65
+ pass
66
+
67
+ def get_action(self) -> dict[str, any]:
68
+ # Get structured data
69
+ streamer_output = self.teleop_streamer.get_streamer_data()
70
+
71
+ # Handle activation using teleop_data commands
72
+ self.check_activation(
73
+ streamer_output.teleop_data, wait_for_activation=self.wait_for_activation
74
+ )
75
+
76
+ action = {}
77
+
78
+ # Process streamer data if active
79
+ if self.is_active and streamer_output.ik_data:
80
+ body_data = streamer_output.ik_data["body_data"]
81
+ left_hand_data = streamer_output.ik_data["left_hand_data"]
82
+ right_hand_data = streamer_output.ik_data["right_hand_data"]
83
+
84
+ left_wrist_name = self.robot_model.supplemental_info.hand_frame_names["left"]
85
+ right_wrist_name = self.robot_model.supplemental_info.hand_frame_names["right"]
86
+ self.latest_left_wrist_data = body_data[left_wrist_name]
87
+ self.latest_right_wrist_data = body_data[right_wrist_name]
88
+ self.latest_left_fingers_data = left_hand_data
89
+ self.latest_right_fingers_data = right_hand_data
90
+
91
+ # TODO: This stores the same data again
92
+ ik_data = {
93
+ "body_data": body_data,
94
+ "left_hand_data": left_hand_data,
95
+ "right_hand_data": right_hand_data,
96
+ }
97
+ action["ik_data"] = ik_data
98
+
99
+ # Wrist poses (pos and quat)
100
+ # TODO: This stores the same wrist poses in two different formats
101
+ left_wrist_matrix = self.latest_left_wrist_data
102
+ right_wrist_matrix = self.latest_right_wrist_data
103
+ left_wrist_pose = np.concatenate(
104
+ [
105
+ left_wrist_matrix[:3, 3],
106
+ R.from_matrix(left_wrist_matrix[:3, :3]).as_quat(scalar_first=True),
107
+ ]
108
+ )
109
+ right_wrist_pose = np.concatenate(
110
+ [
111
+ right_wrist_matrix[:3, 3],
112
+ R.from_matrix(right_wrist_matrix[:3, :3]).as_quat(scalar_first=True),
113
+ ]
114
+ )
115
+
116
+ # Combine IK results with control commands (no teleop_data commands)
117
+ action.update(
118
+ {
119
+ "left_wrist": self.latest_left_wrist_data,
120
+ "right_wrist": self.latest_right_wrist_data,
121
+ "left_fingers": self.latest_left_fingers_data,
122
+ "right_fingers": self.latest_right_fingers_data,
123
+ "wrist_pose": np.concatenate([left_wrist_pose, right_wrist_pose]),
124
+ **streamer_output.control_data, # Only control & data collection commands pass through
125
+ **streamer_output.data_collection_data,
126
+ }
127
+ )
128
+
129
+ # Run retargeting IK
130
+ if "ik_data" in action:
131
+ self.retargeting_ik.set_goal(action["ik_data"])
132
+ action["target_upper_body_pose"] = self.retargeting_ik.get_action()
133
+
134
+ return action
135
+
136
+ def close(self) -> bool:
137
+ self.teleop_streamer.stop_streaming()
138
+ return True
139
+
140
+ def check_activation(self, teleop_data: dict, wait_for_activation: int = 5):
141
+ """Activation logic only looks at teleop data commands"""
142
+ key = self.keyboard_listener.read_msg() if self.keyboard_listener else ""
143
+ toggle_activation_by_keyboard = key == "l"
144
+ reset_teleop_policy_by_keyboard = key == "k"
145
+ toggle_activation_by_teleop = teleop_data.get("toggle_activation", False)
146
+
147
+ if reset_teleop_policy_by_keyboard:
148
+ print("Resetting teleop policy")
149
+ self.reset()
150
+
151
+ if toggle_activation_by_keyboard or toggle_activation_by_teleop:
152
+ self.is_active = not self.is_active
153
+ if self.is_active:
154
+ print("Starting teleop policy")
155
+
156
+ if wait_for_activation > 0 and toggle_activation_by_keyboard:
157
+ print(f"Sleeping for {wait_for_activation} seconds before starting teleop...")
158
+ for i in range(wait_for_activation, 0, -1):
159
+ print(f"Starting in {i}...")
160
+ time.sleep(1)
161
+
162
+ # dda: calibration logic should use current IK data
163
+ self.teleop_streamer.calibrate()
164
+ print("Teleop policy calibrated")
165
+ else:
166
+ print("Stopping teleop policy")
167
+
168
+ @contextmanager
169
+ def activate(self):
170
+ try:
171
+ yield self
172
+ finally:
173
+ self.close()
174
+
175
+ def handle_keyboard_button(self, keycode):
176
+ """
177
+ Handle keyboard input with proper state toggle.
178
+ """
179
+ if keycode == "l":
180
+ # Toggle start state
181
+ self.is_active = not self.is_active
182
+ # Reset initialization when stopping
183
+ if not self.is_active:
184
+ self._initialized = False
185
+ if keycode == "k":
186
+ print("Resetting teleop policy")
187
+ self.reset()
188
+
189
+ def activate_policy(self, wait_for_activation: int = 5):
190
+ """activate the teleop policy"""
191
+ self.is_active = False
192
+ self.check_activation(
193
+ teleop_data={"toggle_activation": True}, wait_for_activation=wait_for_activation
194
+ )
195
+
196
+ def reset(self, wait_for_activation: int = 5, auto_activate: bool = False):
197
+ """Reset the teleop policy to the initial state, and re-activate it."""
198
+ self.teleop_streamer.reset()
199
+ self.retargeting_ik.reset()
200
+ self.is_active = False
201
+ self.latest_left_wrist_data = np.eye(4)
202
+ self.latest_right_wrist_data = np.eye(4)
203
+ self.latest_left_fingers_data = {"position": np.zeros((25, 4, 4))}
204
+ self.latest_right_fingers_data = {"position": np.zeros((25, 4, 4))}
205
+
206
+ if auto_activate:
207
+ self.activate_policy(wait_for_activation)
GR00T-WholeBodyControl/decoupled_wbc/control/policy/wbc_policy_factory.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ from pathlib import Path
3
+ import time
4
+
5
+ import numpy as np
6
+
7
+ import decoupled_wbc
8
+ from decoupled_wbc.control.main.constants import DEFAULT_BASE_HEIGHT, DEFAULT_NAV_CMD
9
+ from decoupled_wbc.control.policy.g1_gear_wbc_policy import G1GearWbcPolicy
10
+ from decoupled_wbc.control.policy.identity_policy import IdentityPolicy
11
+ from decoupled_wbc.control.policy.interpolation_policy import InterpolationPolicy
12
+
13
+ from .g1_decoupled_whole_body_policy import G1DecoupledWholeBodyPolicy
14
+
15
+ WBC_VERSIONS = ["gear_wbc"]
16
+
17
+
18
+ def get_wbc_policy(
19
+ robot_type,
20
+ robot_model,
21
+ wbc_config,
22
+ init_time=time.monotonic(),
23
+ ):
24
+ current_upper_body_pose = robot_model.get_initial_upper_body_pose()
25
+
26
+ if robot_type == "g1":
27
+ upper_body_policy_type = wbc_config.get("upper_body_policy_type", "interpolation")
28
+ if upper_body_policy_type == "identity":
29
+ upper_body_policy = IdentityPolicy()
30
+ else:
31
+ upper_body_policy = InterpolationPolicy(
32
+ init_time=init_time,
33
+ init_values={
34
+ "target_upper_body_pose": current_upper_body_pose,
35
+ "base_height_command": np.array([DEFAULT_BASE_HEIGHT]),
36
+ "navigate_cmd": np.array([DEFAULT_NAV_CMD]),
37
+ },
38
+ max_change_rate=wbc_config["upper_body_max_joint_speed"],
39
+ )
40
+
41
+ lower_body_policy_type = wbc_config.get("VERSION", "gear_wbc")
42
+ if lower_body_policy_type not in ["gear_wbc"]:
43
+ raise ValueError(
44
+ f"Invalid lower body policy version: {lower_body_policy_type}. "
45
+ f"Only 'gear_wbc' is supported."
46
+ )
47
+
48
+ # Get the base path to decoupled_wbc and convert to Path object
49
+ package_path = Path(os.path.dirname(decoupled_wbc.__file__))
50
+ gear_wbc_config = str(package_path / ".." / wbc_config["GEAR_WBC_CONFIG"])
51
+ if lower_body_policy_type == "gear_wbc":
52
+ lower_body_policy = G1GearWbcPolicy(
53
+ robot_model=robot_model,
54
+ config=gear_wbc_config,
55
+ model_path=wbc_config["model_path"],
56
+ )
57
+
58
+ wbc_policy = G1DecoupledWholeBodyPolicy(
59
+ robot_model=robot_model,
60
+ upper_body_policy=upper_body_policy,
61
+ lower_body_policy=lower_body_policy,
62
+ )
63
+ else:
64
+ raise ValueError(f"Invalid robot type: {robot_type}")
65
+ return wbc_policy
GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from .robot_model import ReducedRobotModel, RobotModel
2
+
3
+ __all__ = ["RobotModel", "ReducedRobotModel"]
GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/robot_model.py ADDED
@@ -0,0 +1,772 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from typing import List, Optional, Set, Union
2
+
3
+ import numpy as np
4
+ import pinocchio as pin
5
+
6
+ from decoupled_wbc.control.robot_model.supplemental_info import RobotSupplementalInfo
7
+
8
+
9
+ class RobotModel:
10
+ def __init__(
11
+ self,
12
+ urdf_path,
13
+ asset_path,
14
+ set_floating_base=False,
15
+ supplemental_info: Optional[RobotSupplementalInfo] = None,
16
+ ):
17
+ self.pinocchio_wrapper = pin.RobotWrapper.BuildFromURDF(
18
+ filename=urdf_path,
19
+ package_dirs=[asset_path],
20
+ root_joint=pin.JointModelFreeFlyer() if set_floating_base else None,
21
+ )
22
+ self.is_floating_base_model = set_floating_base
23
+
24
+ self.joint_to_dof_index = {}
25
+ # Assume we only have single-dof joints
26
+ # First two names correspond to universe and floating base joints
27
+ names = (
28
+ self.pinocchio_wrapper.model.names[2:]
29
+ if set_floating_base
30
+ else self.pinocchio_wrapper.model.names[1:]
31
+ )
32
+ for name in names:
33
+ j_id = self.pinocchio_wrapper.model.getJointId(name)
34
+ jmodel = self.pinocchio_wrapper.model.joints[j_id]
35
+ self.joint_to_dof_index[name] = jmodel.idx_q
36
+
37
+ # Store joint limits only for actual joints (excluding floating base)
38
+ # if set floating base is true and the robot can move in the world
39
+ # then we don't want to impose joint limits for the 7 dofs corresponding
40
+ # to the floating base dofs.
41
+ root_nq = 7 if set_floating_base else 0
42
+ self.upper_joint_limits = self.pinocchio_wrapper.model.upperPositionLimit[root_nq:].copy()
43
+ self.lower_joint_limits = self.pinocchio_wrapper.model.lowerPositionLimit[root_nq:].copy()
44
+
45
+ # Set up supplemental info if provided
46
+ self.supplemental_info = supplemental_info
47
+ if self.supplemental_info is not None:
48
+ # Cache indices for body and hand actuated joints separately
49
+ self._body_actuated_joint_indices = [
50
+ self.dof_index(name) for name in self.supplemental_info.body_actuated_joints
51
+ ]
52
+ self._left_hand_actuated_joint_indices = [
53
+ self.dof_index(name) for name in self.supplemental_info.left_hand_actuated_joints
54
+ ]
55
+ self._right_hand_actuated_joint_indices = [
56
+ self.dof_index(name) for name in self.supplemental_info.right_hand_actuated_joints
57
+ ]
58
+ self._hand_actuated_joint_indices = (
59
+ self._left_hand_actuated_joint_indices + self._right_hand_actuated_joint_indices
60
+ )
61
+
62
+ # Cache indices for joint groups, handling nested groups
63
+ self._joint_group_indices = {}
64
+ for group_name, group_info in self.supplemental_info.joint_groups.items():
65
+ indices = []
66
+ # Add indices for direct joints
67
+ indices.extend([self.dof_index(name) for name in group_info["joints"]])
68
+ # Add indices from subgroups
69
+ for subgroup_name in group_info["groups"]:
70
+ indices.extend(self.get_joint_group_indices(subgroup_name))
71
+ self._joint_group_indices[group_name] = sorted(set(indices))
72
+
73
+ # Update joint limits from supplemental info if available
74
+ if (
75
+ hasattr(self.supplemental_info, "joint_limits")
76
+ and self.supplemental_info.joint_limits
77
+ ):
78
+ for joint_name, limits in self.supplemental_info.joint_limits.items():
79
+ if joint_name in self.joint_to_dof_index:
80
+ idx = self.joint_to_dof_index[joint_name] - root_nq
81
+ self.lower_joint_limits[idx] = limits[0]
82
+ self.upper_joint_limits[idx] = limits[1]
83
+
84
+ # Initialize default body pose
85
+ self.default_body_pose = self.q_zero.copy()
86
+
87
+ # Update with supplemental info if available
88
+ if self.supplemental_info is not None:
89
+ default_joint_q = self.supplemental_info.default_joint_q
90
+ for joint, joint_values in default_joint_q.items():
91
+ # Get the joint name mapping for this type
92
+ joint_mapping = self.supplemental_info.joint_name_mapping[joint]
93
+
94
+ # Handle both single joint names and left/right mappings
95
+ if isinstance(joint_mapping, str):
96
+ # Single joint (e.g., waist joints)
97
+ if joint_mapping in self.joint_to_dof_index:
98
+ joint_idx = self.dof_index(joint_mapping)
99
+ self.default_body_pose[joint_idx] = (
100
+ joint_values # joint_values is the value for single joints
101
+ )
102
+ else:
103
+ # Left/right mapping (e.g., arm joints)
104
+ for side, value in joint_values.items():
105
+ if side in joint_mapping and joint_mapping[side] in self.joint_to_dof_index:
106
+ joint_idx = self.dof_index(joint_mapping[side])
107
+ self.default_body_pose[joint_idx] = value
108
+
109
+ # Initialize initial body pose
110
+ self.initial_body_pose = self.default_body_pose.copy()
111
+
112
+ @property
113
+ def num_dofs(self) -> int:
114
+ """Get the number of degrees of freedom of the robot (floating base pose + joints)."""
115
+ return self.pinocchio_wrapper.model.nq
116
+
117
+ @property
118
+ def q_zero(self) -> np.ndarray:
119
+ """Get the zero pose of the robot."""
120
+ return self.pinocchio_wrapper.q0
121
+
122
+ @property
123
+ def joint_names(self) -> List[str]:
124
+ """Get the names of the joints of the robot."""
125
+ return list(self.joint_to_dof_index.keys())
126
+
127
+ @property
128
+ def num_joints(self) -> int:
129
+ """Get the number of joints of the robot."""
130
+ return len(self.joint_to_dof_index)
131
+
132
+ def dof_index(self, joint_name: str) -> int:
133
+ """
134
+ Get the index in the degrees of freedom vector corresponding
135
+ to the single-DoF joint with name `joint_name`.
136
+ """
137
+ if joint_name not in self.joint_to_dof_index:
138
+ raise ValueError(
139
+ f"Unknown joint name: '{joint_name}'. "
140
+ f"Available joints: {list(self.joint_to_dof_index.keys())}"
141
+ )
142
+ return self.joint_to_dof_index[joint_name]
143
+
144
+ def get_body_actuated_joint_indices(self) -> List[int]:
145
+ """
146
+ Get the indices of body actuated joints in the full configuration.
147
+ Ordering is that of the actuated joints as defined in the supplemental info.
148
+ Requires supplemental_info to be provided.
149
+ """
150
+ if self.supplemental_info is None:
151
+ raise ValueError("supplemental_info must be provided to use this method")
152
+ return self._body_actuated_joint_indices
153
+
154
+ def get_hand_actuated_joint_indices(self, side: str = "both") -> List[int]:
155
+ """
156
+ Get the indices of hand actuated joints in the full configuration.
157
+ Ordering is that of the actuated joints as defined in the supplemental info.
158
+ Requires supplemental_info to be provided.
159
+
160
+ Args:
161
+ side: String specifying which hand to get indices for ('left', 'right', or 'both')
162
+ """
163
+ if self.supplemental_info is None:
164
+ raise ValueError("supplemental_info must be provided to use this method")
165
+
166
+ if side.lower() == "both":
167
+ return self._hand_actuated_joint_indices
168
+ elif side.lower() == "left":
169
+ return self._left_hand_actuated_joint_indices
170
+ elif side.lower() == "right":
171
+ return self._right_hand_actuated_joint_indices
172
+ else:
173
+ raise ValueError("side must be 'left', 'right', or 'both'")
174
+
175
+ def get_joint_group_indices(self, group_names: Union[str, Set[str]]) -> List[int]:
176
+ """
177
+ Get the indices of joints in one or more groups in the full configuration.
178
+ Requires supplemental_info to be provided.
179
+ The returned indices are sorted in ascending order, so that the joint ordering
180
+ of the full model is preserved.
181
+
182
+ Args:
183
+ group_names: Either a single group name (str) or a set of group names (Set[str])
184
+
185
+ Returns:
186
+ List of joint indices in sorted order with no duplicates
187
+ """
188
+ if self.supplemental_info is None:
189
+ raise ValueError("supplemental_info must be provided to use this method")
190
+
191
+ # Convert single string to set for uniform handling
192
+ if isinstance(group_names, str):
193
+ group_names = {group_names}
194
+
195
+ # Collect indices from all groups
196
+ all_indices = set()
197
+ for group_name in group_names:
198
+ if group_name not in self._joint_group_indices:
199
+ raise ValueError(f"Unknown joint group: {group_name}")
200
+ all_indices.update(self._joint_group_indices[group_name])
201
+
202
+ return sorted(all_indices)
203
+
204
+ def cache_forward_kinematics(self, q: np.ndarray, auto_clip=True) -> None:
205
+ """
206
+ Perform forward kinematics to update the pose of every joint and frame
207
+ in the Pinocchio data structures for the given configuration `q`.
208
+
209
+ :param q: A numpy array of shape (num_dofs,) representing the robot configuration.
210
+ """
211
+ if q.shape[0] != self.num_dofs:
212
+ raise ValueError(f"Expected q of length {self.num_dofs}, got {q.shape[0]} instead.")
213
+
214
+ # Apply auto-clip if enabled
215
+ if auto_clip:
216
+ q = self.clip_configuration(q)
217
+
218
+ pin.framesForwardKinematics(self.pinocchio_wrapper.model, self.pinocchio_wrapper.data, q)
219
+
220
+ def compute_gravity_compensation_torques(
221
+ self, q: np.ndarray, joint_groups: Union[str, List[str], Set[str]] = None, auto_clip=True
222
+ ) -> np.ndarray:
223
+ """
224
+ Compute gravity compensation torques for specified joint groups using pinocchio.
225
+
226
+ :param q: Robot configuration (joint positions)
227
+ :param joint_groups: Joint groups to compensate (e.g., "arms", ["left_arm", "waist"],
228
+ {"left_arm", "waist"}). If None, compensates all joints
229
+ :param auto_clip: Whether to automatically clip joint values to limits
230
+ :return: Array of gravity compensation torques for all DOFs (zero for non-compensated joints)
231
+ """
232
+ if q.shape[0] != self.num_dofs:
233
+ raise ValueError(f"Expected q of length {self.num_dofs}, got {q.shape[0]} instead.")
234
+
235
+ # Apply auto-clip if enabled
236
+ if auto_clip:
237
+ q = self.clip_configuration(q)
238
+
239
+ try:
240
+ # Cache forward kinematics for the current configuration
241
+ self.cache_forward_kinematics(q, auto_clip=False) # Already clipped if needed
242
+
243
+ # Compute gravity vector using RNEA with zero velocity and acceleration
244
+ v = np.zeros(self.num_dofs)
245
+ a = np.zeros(self.num_dofs)
246
+
247
+ gravity_torques_full = pin.rnea(
248
+ self.pinocchio_wrapper.model, self.pinocchio_wrapper.data, q, v, a
249
+ )
250
+
251
+ # If no joint groups specified, return full gravity torques
252
+ if joint_groups is None:
253
+ return gravity_torques_full
254
+
255
+ # Convert list to set for get_joint_group_indices compatibility
256
+ if isinstance(joint_groups, list):
257
+ joint_groups = set(joint_groups)
258
+
259
+ # Get joint indices for specified groups - get_joint_group_indices handles str and Set[str]
260
+ try:
261
+ compensated_joint_indices = self.get_joint_group_indices(joint_groups)
262
+ except ValueError as e:
263
+ raise ValueError(f"Error resolving joint groups {joint_groups}: {e}")
264
+
265
+ # Create mask for joints that should receive gravity compensation
266
+ compensation_mask = np.zeros(self.num_dofs, dtype=bool)
267
+ for joint_idx in compensated_joint_indices:
268
+ if 0 <= joint_idx < len(compensation_mask):
269
+ compensation_mask[joint_idx] = True
270
+
271
+ # Apply mask to only compensate specified joints
272
+ compensated_torques = np.zeros_like(gravity_torques_full)
273
+ compensated_torques[compensation_mask] = gravity_torques_full[compensation_mask]
274
+
275
+ return compensated_torques
276
+
277
+ except Exception as e:
278
+ raise RuntimeError(f"Error computing gravity compensation: {e}")
279
+
280
+ def clip_configuration(self, q: np.ndarray, margin: float = 1e-6) -> np.ndarray:
281
+ """
282
+ Clip the configuration to stay within joint limits with a small tolerance.
283
+
284
+ :param q: Configuration to clip
285
+ :param margin: Tolerance to keep away from joint limits
286
+ :return: Clipped configuration
287
+ """
288
+ q_clipped = q.copy()
289
+
290
+ # Only clip joint positions, not floating base
291
+ root_nq = 7 if self.is_floating_base_model else 0
292
+ q_clipped[root_nq:] = np.clip(
293
+ q[root_nq:], self.lower_joint_limits + margin, self.upper_joint_limits - margin
294
+ )
295
+
296
+ return q_clipped
297
+
298
+ def frame_placement(self, frame_name: str) -> pin.SE3:
299
+ """
300
+ Returns the SE3 transform of the specified frame in the world coordinate system.
301
+ Note: make sure cache_forward_kinematics() has been previously called.
302
+
303
+ :param frame_name: Name of the frame, e.g. "link_elbow_frame", "hand_imu_frame", etc.
304
+ :return: A pin.SE3 object representing the pose of the frame.
305
+ """
306
+ model = self.pinocchio_wrapper.model
307
+ data = self.pinocchio_wrapper.data
308
+
309
+ frame_id = model.getFrameId(frame_name)
310
+ if frame_id < 0 or frame_id >= len(model.frames):
311
+ valid_frames = [f.name for f in model.frames]
312
+ raise ValueError(f"Unknown frame '{frame_name}'. Valid frames: {valid_frames}")
313
+
314
+ # Pinocchio's data.oMf[frame_id] is a pin.SE3.
315
+ return data.oMf[frame_id].copy()
316
+
317
+ def get_body_actuated_joints(self, q: np.ndarray) -> np.ndarray:
318
+ """
319
+ Get the configuration of body actuated joints from a full configuration.
320
+
321
+ :param q: Configuration in full space
322
+ :return: Configuration of body actuated joints
323
+ """
324
+ indices = self.get_body_actuated_joint_indices()
325
+
326
+ return q[indices]
327
+
328
+ def get_hand_actuated_joints(self, q: np.ndarray, side: str = "both") -> np.ndarray:
329
+ """
330
+ Get the configuration of hand actuated joints from a full configuration.
331
+
332
+ Args:
333
+ q: Configuration in full space
334
+ side: String specifying which hand to get joints for ('left', 'right', or 'both')
335
+ """
336
+ indices = self.get_hand_actuated_joint_indices(side)
337
+ return q[indices]
338
+
339
+ def get_configuration_from_actuated_joints(
340
+ self,
341
+ body_actuated_joint_values: np.ndarray,
342
+ hand_actuated_joint_values: Optional[np.ndarray] = None,
343
+ left_hand_actuated_joint_values: Optional[np.ndarray] = None,
344
+ right_hand_actuated_joint_values: Optional[np.ndarray] = None,
345
+ ) -> np.ndarray:
346
+ """
347
+ Get the full configuration from the body and hand actuated joint configurations.
348
+ Can specify either both hands together or left and right hands separately.
349
+
350
+ Args:
351
+ body_actuated_joint_values: Configuration of body actuated joints
352
+ hand_actuated_joint_values: Configuration of both hands' actuated joints (optional)
353
+ left_hand_actuated_joint_values: Configuration of left hand actuated joints (optional)
354
+ right_hand_actuated_joint_values: Configuration of right hand actuated joints (optional)
355
+
356
+ Returns:
357
+ Full configuration including body and hand joints
358
+ """
359
+ q = self.pinocchio_wrapper.q0.copy()
360
+ q[self.get_body_actuated_joint_indices()] = body_actuated_joint_values
361
+
362
+ # Handle hand configurations
363
+ if hand_actuated_joint_values is not None:
364
+ # Use combined hand configuration
365
+ q[self.get_hand_actuated_joint_indices("both")] = hand_actuated_joint_values
366
+ else:
367
+ # Use separate hand configurations
368
+ if left_hand_actuated_joint_values is not None:
369
+ q[self.get_hand_actuated_joint_indices("left")] = left_hand_actuated_joint_values
370
+ if right_hand_actuated_joint_values is not None:
371
+ q[self.get_hand_actuated_joint_indices("right")] = right_hand_actuated_joint_values
372
+
373
+ return q
374
+
375
+ def reset_forward_kinematics(self) -> None:
376
+ """
377
+ Reset the forward kinematics to the initial configuration.
378
+ """
379
+ self.cache_forward_kinematics(self.q_zero)
380
+
381
+ def get_initial_upper_body_pose(self) -> np.ndarray:
382
+ """
383
+ Get the initial upper body pose of the robot.
384
+ """
385
+ return self.initial_body_pose[self.get_joint_group_indices("upper_body")]
386
+
387
+ def get_default_body_pose(self) -> np.ndarray:
388
+ """
389
+ Get the default body pose of the robot.
390
+ """
391
+ return self.default_body_pose
392
+
393
+ def set_initial_body_pose(self, q: np.ndarray, q_idx=None) -> None:
394
+ """
395
+ Set the initial body pose of the robot.
396
+ """
397
+ if q_idx is None:
398
+ self.initial_body_pose = q
399
+ else:
400
+ self.initial_body_pose[q_idx] = q
401
+
402
+
403
+ class ReducedRobotModel(RobotModel):
404
+ """
405
+ A class that creates a reduced order robot model by fixing certain joints.
406
+ This class maintains a mapping between the reduced state space and the full state space.
407
+ """
408
+
409
+ def __init__(
410
+ self,
411
+ full_robot_model: RobotModel,
412
+ fixed_joints: List[str],
413
+ fixed_values: Optional[List[float]] = None,
414
+ ):
415
+ """
416
+ Create a reduced order robot model by fixing specified joints.
417
+
418
+ :param full_robot_model: The original robot model
419
+ :param fixed_joints: List of joint names to fix
420
+ :param fixed_values: Optional list of values to fix the joints to. If None, uses the initial
421
+ joint positions (q0) from the full robot model.
422
+ """
423
+ self.full_robot = full_robot_model
424
+ self.supplemental_info = full_robot_model.supplemental_info
425
+
426
+ # If fixed_values is None, use q0 from the full robot model
427
+ if fixed_values is None:
428
+ fixed_values = []
429
+ for joint_name in fixed_joints:
430
+ full_idx = full_robot_model.dof_index(joint_name)
431
+ fixed_values.append(full_robot_model.pinocchio_wrapper.q0[full_idx])
432
+ elif len(fixed_joints) != len(fixed_values):
433
+ raise ValueError("fixed_joints and fixed_values must have the same length")
434
+
435
+ # Store fixed joints and their values
436
+ self.fixed_joints = fixed_joints
437
+ self.fixed_values = fixed_values
438
+
439
+ # Create mapping between reduced and full state spaces
440
+ self.reduced_to_full = []
441
+ self.full_to_reduced = {}
442
+
443
+ # Initialize with floating base indices if present
444
+ if full_robot_model.is_floating_base_model:
445
+ self.reduced_to_full.extend(range(7)) # Floating base indices
446
+ for i in range(7):
447
+ self.full_to_reduced[i] = i
448
+
449
+ # Add active joint indices
450
+ for joint_name in full_robot_model.joint_names:
451
+ if joint_name not in fixed_joints:
452
+ full_idx = full_robot_model.dof_index(joint_name)
453
+ reduced_idx = len(self.reduced_to_full)
454
+ self.reduced_to_full.append(full_idx)
455
+ self.full_to_reduced[full_idx] = reduced_idx
456
+
457
+ # Create a reduced Pinocchio model using buildReducedModel
458
+ # First, get the list of joint IDs to lock
459
+ locked_joint_ids = []
460
+ for joint_name in fixed_joints:
461
+ joint_id = full_robot_model.pinocchio_wrapper.model.getJointId(joint_name)
462
+ if (full_robot_model.is_floating_base_model and joint_id > 1) or (
463
+ not full_robot_model.is_floating_base_model and joint_id > 0
464
+ ):
465
+ locked_joint_ids.append(joint_id)
466
+
467
+ # First build the reduced kinematic model
468
+ reduced_model = pin.buildReducedModel(
469
+ full_robot_model.pinocchio_wrapper.model,
470
+ locked_joint_ids,
471
+ full_robot_model.pinocchio_wrapper.q0,
472
+ )
473
+
474
+ # Then build the reduced geometry models using the reduced kinematic model
475
+ self.pinocchio_wrapper = pin.RobotWrapper(
476
+ model=reduced_model,
477
+ )
478
+
479
+ # Create joint to dof index mapping
480
+ self.joint_to_dof_index = {}
481
+ # Assume we only have single-dof joints
482
+ # First two names correspond to universe and floating base joints
483
+ names = (
484
+ self.pinocchio_wrapper.model.names[2:]
485
+ if self.full_robot.is_floating_base_model
486
+ else self.pinocchio_wrapper.model.names[1:]
487
+ )
488
+ for name in names:
489
+ j_id = self.pinocchio_wrapper.model.getJointId(name)
490
+ jmodel = self.pinocchio_wrapper.model.joints[j_id]
491
+ self.joint_to_dof_index[name] = jmodel.idx_q
492
+
493
+ # Initialize joint limits
494
+ root_nq = 7 if self.full_robot.is_floating_base_model else 0
495
+ self.lower_joint_limits = self.pinocchio_wrapper.model.lowerPositionLimit[root_nq:].copy()
496
+ self.upper_joint_limits = self.pinocchio_wrapper.model.upperPositionLimit[root_nq:].copy()
497
+
498
+ # Update joint limits from supplemental info if available
499
+ if self.supplemental_info is not None:
500
+ if (
501
+ hasattr(self.supplemental_info, "joint_limits")
502
+ and self.supplemental_info.joint_limits
503
+ ):
504
+ for joint_name, limits in self.supplemental_info.joint_limits.items():
505
+ if joint_name in self.joint_to_dof_index:
506
+ idx = self.joint_to_dof_index[joint_name] - root_nq
507
+ self.lower_joint_limits[idx] = limits[0]
508
+ self.upper_joint_limits[idx] = limits[1]
509
+
510
+ # Get full indices for body and hand actuated joints
511
+ full_body_indices = full_robot_model.get_body_actuated_joint_indices()
512
+ full_hand_indices = full_robot_model.get_hand_actuated_joint_indices("both")
513
+ full_left_hand_indices = full_robot_model.get_hand_actuated_joint_indices("left")
514
+ full_right_hand_indices = full_robot_model.get_hand_actuated_joint_indices("right")
515
+
516
+ # Map to reduced indices
517
+ self._body_actuated_joint_indices = []
518
+ for idx in full_body_indices:
519
+ if idx in self.full_to_reduced:
520
+ self._body_actuated_joint_indices.append(self.full_to_reduced[idx])
521
+
522
+ self._hand_actuated_joint_indices = []
523
+ for idx in full_hand_indices:
524
+ if idx in self.full_to_reduced:
525
+ self._hand_actuated_joint_indices.append(self.full_to_reduced[idx])
526
+
527
+ self._left_hand_actuated_joint_indices = []
528
+ for idx in full_left_hand_indices:
529
+ if idx in self.full_to_reduced:
530
+ self._left_hand_actuated_joint_indices.append(self.full_to_reduced[idx])
531
+
532
+ self._right_hand_actuated_joint_indices = []
533
+ for idx in full_right_hand_indices:
534
+ if idx in self.full_to_reduced:
535
+ self._right_hand_actuated_joint_indices.append(self.full_to_reduced[idx])
536
+
537
+ # Cache indices for joint groups in reduced space
538
+ self._joint_group_indices = {}
539
+ for group_name in self.supplemental_info.joint_groups:
540
+ full_indices = full_robot_model.get_joint_group_indices(group_name)
541
+ reduced_indices = []
542
+ for idx in full_indices:
543
+ if idx in self.full_to_reduced:
544
+ reduced_indices.append(self.full_to_reduced[idx])
545
+ self._joint_group_indices[group_name] = sorted(set(reduced_indices))
546
+
547
+ # Initialize default body pose in reduced space
548
+ self.default_body_pose = self.full_to_reduced_configuration(
549
+ full_robot_model.default_body_pose
550
+ )
551
+
552
+ # Initialize initial body pose in reduced space
553
+ self.initial_body_pose = self.full_to_reduced_configuration(
554
+ full_robot_model.initial_body_pose
555
+ )
556
+
557
+ @property
558
+ def num_joints(self) -> int:
559
+ """Get the number of active joints in the reduced model."""
560
+ return len(self.joint_names)
561
+
562
+ @property
563
+ def joint_names(self) -> List[str]:
564
+ """Get the names of the active joints in the reduced model."""
565
+ return [name for name in self.full_robot.joint_names if name not in self.fixed_joints]
566
+
567
+ @classmethod
568
+ def from_fixed_groups(
569
+ cls,
570
+ full_robot_model: RobotModel,
571
+ fixed_group_names: List[str],
572
+ fixed_values: Optional[List[float]] = None,
573
+ ) -> "ReducedRobotModel":
574
+ """
575
+ Create a reduced order robot model by fixing all joints in specified groups.
576
+
577
+ :param full_robot_model: The original robot model
578
+ :param fixed_group_names: List of joint group names to fix
579
+ :param fixed_values: Optional list of values to fix the joints to. If None, uses the initial
580
+ joint positions (q0) from the full robot model.
581
+ :return: A ReducedRobotModel instance
582
+ """
583
+ if full_robot_model.supplemental_info is None:
584
+ raise ValueError("supplemental_info must be provided to use this method")
585
+
586
+ # Get all joints in the groups, including those from subgroups
587
+ fixed_joints = set() # Use a set to avoid duplicates
588
+
589
+ for group_name in fixed_group_names:
590
+ if group_name not in full_robot_model.supplemental_info.joint_groups:
591
+ raise ValueError(f"Unknown joint group: {group_name}")
592
+
593
+ group_info = full_robot_model.supplemental_info.joint_groups[group_name]
594
+
595
+ # Add direct joints
596
+ fixed_joints.update(group_info["joints"])
597
+
598
+ # Add joints from subgroups
599
+ for subgroup_name in group_info["groups"]:
600
+ subgroup_joints = full_robot_model.get_joint_group_indices(subgroup_name)
601
+ fixed_joints.update([full_robot_model.joint_names[idx] for idx in subgroup_joints])
602
+
603
+ # Convert set back to list for compatibility with the original constructor
604
+ return cls(full_robot_model, list(fixed_joints), fixed_values)
605
+
606
+ @classmethod
607
+ def from_fixed_group(
608
+ cls,
609
+ full_robot_model: RobotModel,
610
+ fixed_group_name: str,
611
+ fixed_values: Optional[List[float]] = None,
612
+ ) -> "ReducedRobotModel":
613
+ """
614
+ Create a reduced order robot model by fixing all joints in a specified group.
615
+ This is a convenience method that calls from_fixed_groups with a single group.
616
+
617
+ :param full_robot_model: The original robot model
618
+ :param fixed_group_name: Name of the joint group to fix
619
+ :param fixed_values: Optional list of values to fix the joints to. If None, uses the initial
620
+ joint positions (q0) from the full robot model.
621
+ :return: A ReducedRobotModel instance
622
+ """
623
+ return cls.from_fixed_groups(full_robot_model, [fixed_group_name], fixed_values)
624
+
625
+ @classmethod
626
+ def from_active_group(
627
+ cls,
628
+ full_robot_model: RobotModel,
629
+ active_group_name: str,
630
+ fixed_values: Optional[List[float]] = None,
631
+ ) -> "ReducedRobotModel":
632
+ """
633
+ Create a reduced order robot model by fixing all joints EXCEPT those in the specified group.
634
+ This is a convenience method that calls from_active_groups with a single group.
635
+
636
+ :param full_robot_model: The original robot model
637
+ :param active_group_name: Name of the joint group to keep active (all other joints will be fixed)
638
+ :param fixed_values: Optional list of values to fix the joints to. If None, uses the initial
639
+ joint positions (q0) from the full robot model.
640
+ :return: A ReducedRobotModel instance
641
+ """
642
+ return cls.from_active_groups(full_robot_model, [active_group_name], fixed_values)
643
+
644
+ @classmethod
645
+ def from_active_groups(
646
+ cls,
647
+ full_robot_model: RobotModel,
648
+ active_group_names: List[str],
649
+ fixed_values: Optional[List[float]] = None,
650
+ ) -> "ReducedRobotModel":
651
+ """
652
+ Create a reduced order robot model by fixing all joints EXCEPT those in the specified groups.
653
+ This is useful when you want to keep multiple groups active and fix everything else.
654
+
655
+ :param full_robot_model: The original robot model
656
+ :param active_group_names: List of joint group names to keep active (all other joints will be fixed)
657
+ :param fixed_values: Optional list of values to fix the joints to. If None, uses the initial
658
+ joint positions (q0) from the full robot model.
659
+ :return: A ReducedRobotModel instance
660
+ """
661
+ if full_robot_model.supplemental_info is None:
662
+ raise ValueError("supplemental_info must be provided to use this method")
663
+
664
+ # Get all joints in the active groups, including those from subgroups
665
+ active_joints = set()
666
+
667
+ def add_group_joints(group_name: str):
668
+ if group_name not in full_robot_model.supplemental_info.joint_groups:
669
+ raise ValueError(f"Unknown joint group: {group_name}")
670
+
671
+ group_info = full_robot_model.supplemental_info.joint_groups[group_name]
672
+
673
+ # Add direct joints
674
+ if "joints" in group_info:
675
+ active_joints.update(group_info["joints"])
676
+
677
+ # Add joints from subgroups
678
+ if "groups" in group_info:
679
+ for subgroup_name in group_info["groups"]:
680
+ add_group_joints(subgroup_name)
681
+
682
+ for group_name in active_group_names:
683
+ add_group_joints(group_name)
684
+
685
+ # Get all joints from the model
686
+ all_joints = set(full_robot_model.joint_names)
687
+
688
+ # The fixed joints are all joints minus the active joints
689
+ fixed_joints = list(all_joints - active_joints)
690
+
691
+ return cls(full_robot_model, fixed_joints, fixed_values)
692
+
693
+ def reduced_to_full_configuration(self, q_reduced: np.ndarray) -> np.ndarray:
694
+ """
695
+ Convert a reduced configuration to the full configuration space.
696
+
697
+ :param q_reduced: Configuration in reduced space
698
+ :return: Configuration in full space with fixed joints set to their fixed values
699
+ """
700
+ if q_reduced.shape[0] != self.num_dofs:
701
+ raise ValueError(
702
+ f"Expected q_reduced of length {self.num_dofs}, got {q_reduced.shape[0]} instead"
703
+ )
704
+
705
+ q_full = np.zeros(self.full_robot.num_dofs)
706
+
707
+ # Set active joints
708
+ for reduced_idx, full_idx in enumerate(self.reduced_to_full):
709
+ q_full[full_idx] = q_reduced[reduced_idx]
710
+
711
+ # Set fixed joints
712
+ for joint_name, value in zip(self.fixed_joints, self.fixed_values):
713
+ full_idx = self.full_robot.dof_index(joint_name)
714
+ q_full[full_idx] = value
715
+
716
+ return q_full
717
+
718
+ def full_to_reduced_configuration(self, q_full: np.ndarray) -> np.ndarray:
719
+ """
720
+ Convert a full configuration to the reduced configuration space.
721
+
722
+ :param q_full: Configuration in full space
723
+ :return: Configuration in reduced space
724
+ """
725
+ if q_full.shape[0] != self.full_robot.num_dofs:
726
+ raise ValueError(
727
+ f"Expected q_full of length {self.full_robot.num_dofs}, got {q_full.shape[0]} instead"
728
+ )
729
+
730
+ q_reduced = np.zeros(self.num_dofs)
731
+
732
+ # Copy active joints
733
+ for reduced_idx, full_idx in enumerate(self.reduced_to_full):
734
+ q_reduced[reduced_idx] = q_full[full_idx]
735
+
736
+ return q_reduced
737
+
738
+ def cache_forward_kinematics(self, q_reduced: np.ndarray, auto_clip=True) -> None:
739
+ """
740
+ Perform forward kinematics using the reduced configuration.
741
+
742
+ :param q_reduced: Configuration in reduced space
743
+ """
744
+ # First update the full robot's forward kinematics
745
+ q_full = self.reduced_to_full_configuration(q_reduced)
746
+ self.full_robot.cache_forward_kinematics(q_full, auto_clip)
747
+
748
+ # Then update the reduced model's forward kinematics
749
+ pin.framesForwardKinematics(
750
+ self.pinocchio_wrapper.model, self.pinocchio_wrapper.data, q_reduced
751
+ )
752
+
753
+ def clip_configuration(self, q_reduced: np.ndarray, margin: float = 1e-6) -> np.ndarray:
754
+ """
755
+ Clip the reduced configuration to stay within joint limits with a small tolerance.
756
+
757
+ :param q_reduced: Configuration to clip
758
+ :param margin: Tolerance to keep away from joint limits
759
+ :return: Clipped configuration
760
+ """
761
+ q_full = self.reduced_to_full_configuration(q_reduced)
762
+ q_full_clipped = self.full_robot.clip_configuration(q_full, margin)
763
+ return self.full_to_reduced_configuration(q_full_clipped)
764
+
765
+ def reset_forward_kinematics(self):
766
+ """
767
+ Reset the forward kinematics to the initial configuration.
768
+ """
769
+ # Reset full robot's forward kinematics
770
+ self.full_robot.reset_forward_kinematics()
771
+ # Reset reduced model's forward kinematics
772
+ self.cache_forward_kinematics(self.q_zero)
GR00T-WholeBodyControl/decoupled_wbc/control/sensor/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/sensor/composed_camera.py ADDED
@@ -0,0 +1,440 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections import deque
2
+ from dataclasses import dataclass
3
+ import queue
4
+ import threading
5
+ import time
6
+ from typing import Any, Dict, Optional
7
+
8
+ # we need to import these first in this order to avoid TSL segmentation fault
9
+ # caused by zed and oak libraries
10
+ try:
11
+ import cv2 # noqa
12
+ import depthai as dai # noqa
13
+ import pyzed.sl as sl # noqa
14
+ except ImportError:
15
+ print(
16
+ """
17
+ Some of the camera specific dependencies are not installed. If you are
18
+ not running this on the robot, having these libraries is optional.
19
+ """
20
+ )
21
+
22
+ import numpy as np # noqa
23
+
24
+ from decoupled_wbc.control.base.sensor import Sensor
25
+ from decoupled_wbc.control.sensor.sensor_server import (
26
+ ImageMessageSchema,
27
+ SensorClient,
28
+ SensorServer,
29
+ CameraMountPosition,
30
+ )
31
+
32
+
33
+ def read_qr_code(data):
34
+ current_time = time.monotonic()
35
+ detector = cv2.QRCodeDetector()
36
+ for key, img in data["images"].items():
37
+ decoded_time, bbox, _ = detector.detectAndDecode(img)
38
+ if bbox is not None and decoded_time:
39
+ print(f"{key} latency: {(current_time - float(decoded_time)) * 1e3:.1f} ms")
40
+ else:
41
+ print(f"{key} QR code not detected.")
42
+
43
+
44
+ @dataclass
45
+ class ComposedCameraConfig:
46
+ """Camera configuration for composed camera"""
47
+
48
+ ego_view_camera: Optional[str] = "oak"
49
+ """Camera type for ego view: oak, realsense, zed, or None"""
50
+
51
+ ego_view_device_id: Optional[str] = None
52
+ """Device ID for ego view camera (optional, used for OAK cameras)"""
53
+
54
+ head_camera: Optional[str] = None
55
+ """Camera type for head view: oak, oak_mono, realsense, zed or None"""
56
+
57
+ head_device_id: Optional[str] = None
58
+ """Device ID for head camera (optional, used for OAK cameras)"""
59
+
60
+ left_wrist_camera: Optional[str] = None
61
+ """Camera type for left wrist view: oak, realsense, zed or None"""
62
+
63
+ left_wrist_device_id: Optional[str] = None
64
+ """Device ID for left wrist camera (optional, used for OAK cameras)"""
65
+
66
+ right_wrist_camera: Optional[str] = None
67
+ """Camera type for right wrist view: oak, realsense, zed or None"""
68
+
69
+ right_wrist_device_id: Optional[str] = None
70
+ """Device ID for right wrist camera (optional, used for OAK cameras)"""
71
+
72
+ fps: int = 30
73
+ """Rate at which the composed camera will publish the images. Since composed camera
74
+ can read from multiple cameras, it will publish all the images.
75
+ Note that OAK can only run at 30 FPS. 20 FPS will cause large latency.
76
+ """
77
+
78
+ # Server configuration
79
+ run_as_server: bool = True
80
+ """Whether to run as server or client"""
81
+
82
+ server: bool = True
83
+ """Whether to run the camera as a server"""
84
+
85
+ port: int = 5555
86
+ """Port number for server/client communication"""
87
+
88
+ test_latency: bool = False
89
+ """Whether to test latency"""
90
+
91
+ # Queue configuration
92
+ queue_size: int = 3
93
+ """Size of each camera's image queue"""
94
+
95
+ def __post_init__(self):
96
+ # runyu: Note that this is a hack to make the config work with G1 camera server in orin
97
+ # we should not use this hack in the future
98
+ self.run_as_server: bool = self.server
99
+
100
+
101
+ class ComposedCameraSensor(Sensor, SensorServer):
102
+
103
+ def __init__(self, config: ComposedCameraConfig):
104
+ self.config = config
105
+ self.camera_queues: Dict[str, queue.Queue] = {}
106
+ self.camera_threads: Dict[str, threading.Thread] = {}
107
+ self.shutdown_events: Dict[str, threading.Event] = {}
108
+ self.error_events: Dict[str, threading.Event] = {}
109
+ self.error_messages: Dict[str, str] = {}
110
+ self._observation_spaces: Dict[str, Any] = {}
111
+
112
+ camera_configs = self._get_camera_configs()
113
+
114
+ # Then create worker threads
115
+ for mount_position, camera_config in camera_configs.items():
116
+ # Create queue and shutdown event for this camera
117
+ camera_queue = queue.Queue(maxsize=config.queue_size)
118
+ shutdown_event = threading.Event()
119
+ error_event = threading.Event()
120
+
121
+ self.camera_queues[mount_position] = camera_queue
122
+ self.shutdown_events[mount_position] = shutdown_event
123
+ self.error_events[mount_position] = error_event
124
+
125
+ # Start camera thread
126
+ thread = threading.Thread(
127
+ target=self._camera_worker_wrapper,
128
+ args=(
129
+ mount_position,
130
+ camera_config["camera_type"],
131
+ camera_config["device_id"],
132
+ camera_queue,
133
+ shutdown_event,
134
+ error_event,
135
+ ),
136
+ )
137
+ thread.start()
138
+ self.camera_threads[mount_position] = thread
139
+
140
+ if config.run_as_server:
141
+ self.start_server(config.port)
142
+
143
+ def _get_camera_configs(self) -> Dict[str, str]:
144
+ """Get camera configurations as mount_position -> camera_type mapping"""
145
+ camera_configs = {}
146
+
147
+ if self.config.ego_view_camera is not None:
148
+ camera_configs[CameraMountPosition.EGO_VIEW.value] = {
149
+ "camera_type": self.config.ego_view_camera,
150
+ "device_id": self.config.ego_view_device_id,
151
+ }
152
+
153
+ if self.config.head_camera is not None:
154
+ camera_configs[CameraMountPosition.HEAD.value] = {
155
+ "camera_type": self.config.head_camera,
156
+ "device_id": self.config.head_device_id,
157
+ }
158
+
159
+ if self.config.left_wrist_camera is not None:
160
+ camera_configs[CameraMountPosition.LEFT_WRIST.value] = {
161
+ "camera_type": self.config.left_wrist_camera,
162
+ "device_id": self.config.left_wrist_device_id,
163
+ }
164
+
165
+ if self.config.right_wrist_camera is not None:
166
+ camera_configs[CameraMountPosition.RIGHT_WRIST.value] = {
167
+ "camera_type": self.config.right_wrist_camera,
168
+ "device_id": self.config.right_wrist_device_id,
169
+ }
170
+
171
+ return camera_configs
172
+
173
+ def _camera_worker_wrapper(
174
+ self,
175
+ mount_position: str,
176
+ camera_type: str,
177
+ device_id: Optional[str],
178
+ image_queue: queue.Queue,
179
+ shutdown_event: threading.Event,
180
+ error_event: threading.Event,
181
+ ):
182
+ """Worker thread that continuously captures from a single camera"""
183
+ try:
184
+ camera = self._instantiate_camera(mount_position, camera_type, device_id)
185
+ self._observation_spaces[mount_position] = camera.observation_space()
186
+
187
+ consecutive_failures = 0
188
+ max_consecutive_failures = 5
189
+
190
+ while not shutdown_event.is_set():
191
+ frame = camera.read()
192
+ if frame:
193
+ consecutive_failures = 0 # Reset on successful read
194
+ # Non-blocking queue put with frame dropping
195
+ try:
196
+ image_queue.put_nowait(frame)
197
+ except queue.Full:
198
+ # Remove oldest frame and add new one
199
+ try:
200
+ image_queue.get_nowait()
201
+ image_queue.put_nowait(frame)
202
+ except queue.Empty:
203
+ pass
204
+ else:
205
+ consecutive_failures += 1
206
+ if consecutive_failures >= max_consecutive_failures:
207
+ error_msg = (
208
+ f"Camera {mount_position} ({camera_type}) dropped: "
209
+ f"failed to read {consecutive_failures} consecutive frames"
210
+ )
211
+ print(f"[ERROR] {error_msg}")
212
+ self.error_messages[mount_position] = error_msg
213
+ error_event.set()
214
+ break
215
+
216
+ camera.close()
217
+
218
+ except Exception as e:
219
+ error_msg = f"Camera {mount_position} ({camera_type}) error: {str(e)}"
220
+ print(f"[ERROR] {error_msg}")
221
+ self.error_messages[mount_position] = error_msg
222
+ error_event.set()
223
+
224
+ def _instantiate_camera(
225
+ self, mount_position: str, camera_type: str, device_id: Optional[str] = None
226
+ ) -> Sensor:
227
+ """
228
+ Instantiate a camera sensor based on the camera type.
229
+
230
+ Args:
231
+ camera_type: Type of camera ("oak", "oak_mono", "realsense", "zed")
232
+ device_id: Optional device ID for the camera (used for OAK cameras)
233
+
234
+ Returns:
235
+ Sensor instance for the specified camera type
236
+ """
237
+ if camera_type in ("oak", "oak_mono"):
238
+ from decoupled_wbc.control.sensor.oak import OAKConfig, OAKSensor
239
+
240
+ oak_config = OAKConfig()
241
+ if camera_type == "oak_mono":
242
+ oak_config.enable_mono_cameras = True
243
+ print("Initializing OAK sensor for camera type: ", camera_type)
244
+ return OAKSensor(config=oak_config, mount_position=mount_position, device_id=device_id)
245
+ elif camera_type == "realsense":
246
+ from decoupled_wbc.control.sensor.realsense import RealSenseSensor
247
+
248
+ print("Initializing RealSense sensor for camera type: ", camera_type)
249
+ return RealSenseSensor(mount_position=mount_position)
250
+ elif camera_type == "zed":
251
+ from decoupled_wbc.control.sensor.zed import ZEDSensor
252
+
253
+ print("Initializing ZED sensor for camera type: ", camera_type)
254
+ return ZEDSensor(mount_position=mount_position)
255
+ elif camera_type.endswith(".mp4"):
256
+ from decoupled_wbc.control.sensor.dummy import ReplayDummySensor
257
+
258
+ print("Initializing Replay Dummy Sensor for camera type: ", camera_type)
259
+ return ReplayDummySensor(video_path=camera_type)
260
+ else:
261
+ raise ValueError(f"Unsupported camera type: {camera_type}")
262
+
263
+ def _check_for_errors(self):
264
+ """Check if any camera thread has encountered an error and raise exception if so."""
265
+ for mount_position, error_event in self.error_events.items():
266
+ if error_event.is_set():
267
+ error_msg = self.error_messages.get(
268
+ mount_position, f"Camera {mount_position} encountered an unknown error"
269
+ )
270
+ raise RuntimeError(error_msg)
271
+
272
+ def read(self):
273
+ """Read frames from all cameras."""
274
+ # Check for errors from camera threads
275
+ self._check_for_errors()
276
+
277
+ message = {}
278
+ for mount_position, camera_queue in self.camera_queues.items():
279
+ frame = self._get_latest_from_queue(camera_queue)
280
+ if frame is not None:
281
+ message[mount_position] = frame
282
+ return message
283
+
284
+ def _get_latest_from_queue(self, camera_queue: queue.Queue) -> Optional[Dict[str, Any]]:
285
+ """Get most recent frame, discard older ones"""
286
+ latest = None
287
+ try:
288
+ while True:
289
+ latest = camera_queue.get_nowait()
290
+ except queue.Empty:
291
+ pass
292
+ return latest
293
+
294
+ def close(self):
295
+ """Close all cameras."""
296
+ # Signal all worker threads to shutdown
297
+ for shutdown_event in self.shutdown_events.values():
298
+ shutdown_event.set()
299
+
300
+ # Wait for all threads to finish
301
+ for thread in self.camera_threads.values():
302
+ thread.join(timeout=5.0)
303
+
304
+ # Clear queues
305
+ for camera_queue in self.camera_queues.values():
306
+ try:
307
+ while True:
308
+ camera_queue.get_nowait()
309
+ except queue.Empty:
310
+ pass
311
+
312
+ # Stop server if running
313
+ if self.config.run_as_server:
314
+ self.stop_server()
315
+
316
+ def serialize_message(self, message: Dict[str, Any]) -> Dict[str, Any]:
317
+ """Merge all camera data into a single ImageMessageSchema."""
318
+ all_timestamps = {}
319
+ all_images = {}
320
+
321
+ for _, camera_data in message.items():
322
+ all_timestamps.update(camera_data.get("timestamps", {}))
323
+ all_images.update(camera_data.get("images", {}))
324
+
325
+ # Create a single ImageMessageSchema with all data
326
+ img_schema = ImageMessageSchema(timestamps=all_timestamps, images=all_images)
327
+ return img_schema.serialize()
328
+
329
+ def run_server(self):
330
+ """Run the server."""
331
+ idx = 0
332
+ server_start_time = time.monotonic()
333
+ fps_print_time = time.monotonic()
334
+ frame_interval = 1.0 / self.config.fps
335
+
336
+ while True:
337
+ # Calculate when this frame should ideally complete
338
+ target_time = server_start_time + (idx + 1) * frame_interval
339
+
340
+ message = self.read()
341
+ if message:
342
+ if self.config.test_latency:
343
+ read_qr_code(message)
344
+
345
+ serialized_message = self.serialize_message(message)
346
+ self.send_message(serialized_message)
347
+ idx += 1
348
+
349
+ if idx % 10 == 0:
350
+ print(f"Image sending FPS: {10 / (time.monotonic() - fps_print_time):.2f}")
351
+ fps_print_time = time.monotonic()
352
+
353
+ # Sleep to maintain precise timing
354
+ current_time = time.monotonic()
355
+ sleep_time = target_time - current_time
356
+ if sleep_time > 0:
357
+ time.sleep(sleep_time)
358
+ else:
359
+ # If we're behind, increment idx to stay on schedule
360
+ if not message:
361
+ idx += 1
362
+
363
+ def observation_space(self):
364
+ """Return the observation space."""
365
+ import gymnasium as gym
366
+
367
+ return gym.spaces.Dict(self._observation_spaces)
368
+
369
+
370
+ class ComposedCameraClientSensor(Sensor, SensorClient):
371
+ """Class that serves as client for multiple different cameras."""
372
+
373
+ def __init__(self, server_ip: str = "localhost", port: int = 5555):
374
+ self.start_client(server_ip, port)
375
+
376
+ # Initialize tracking variables
377
+ self._latest_message = {}
378
+ self._avg_time_per_frame = deque(maxlen=20)
379
+ self._msg_received_time = 0
380
+ self._start_time = 0.0 # Initialize _start_time
381
+ self.idx = 0
382
+
383
+ print("Initialized composed camera client sensor")
384
+
385
+ def read(self, **kwargs) -> Optional[Dict[str, Any]]:
386
+ self._start_time = time.time()
387
+ message = self.receive_message()
388
+ if not message:
389
+ return None
390
+ self.idx += 1
391
+
392
+ self._latest_message = ImageMessageSchema.deserialize(message).asdict()
393
+
394
+ # if self.idx % 10 == 0:
395
+ # for image_key, image_time in self._latest_message["timestamps"].items():
396
+ # image_latency = (time.time() - image_time) * 1000
397
+ # print(f"Image latency for {image_key}: {image_latency:.2f} ms")
398
+
399
+ self._msg_received_time = time.time()
400
+ self._avg_time_per_frame.append(self._msg_received_time - self._start_time)
401
+
402
+ return self._latest_message
403
+
404
+ def close(self):
405
+ """Close the client connection."""
406
+ self.stop_client()
407
+
408
+ def fps(self) -> float:
409
+ """Get the current FPS of the client."""
410
+ if len(self._avg_time_per_frame) == 0:
411
+ return 0.0
412
+ return float(1 / np.mean(self._avg_time_per_frame))
413
+
414
+
415
+ if __name__ == "__main__":
416
+ """Test function for ComposedCamera."""
417
+ import tyro
418
+
419
+ config = tyro.cli(ComposedCameraConfig)
420
+
421
+ if config.run_as_server:
422
+ composed_camera = ComposedCameraSensor(config)
423
+ print("Running composed camera server...")
424
+ composed_camera.run_server()
425
+
426
+ else:
427
+ # Client mode
428
+ composed_client = ComposedCameraClientSensor(server_ip="localhost", port=config.port)
429
+
430
+ try:
431
+ while True:
432
+ data = composed_client.read()
433
+ if data is not None:
434
+ print(f"FPS: {composed_client.fps():.2f}")
435
+ if "timestamp" in data:
436
+ print(f"Timestamp: {data['timestamp']}")
437
+ time.sleep(0.1)
438
+ except KeyboardInterrupt:
439
+ print("Stopping client...")
440
+ composed_client.close()
GR00T-WholeBodyControl/decoupled_wbc/control/sensor/oak.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import Any, Dict, Optional, Tuple
3
+
4
+ import cv2
5
+ import depthai as dai
6
+ import gymnasium as gym
7
+ import numpy as np
8
+
9
+ from decoupled_wbc.control.base.sensor import Sensor
10
+ from decoupled_wbc.control.sensor.sensor_server import (
11
+ CameraMountPosition,
12
+ ImageMessageSchema,
13
+ SensorServer,
14
+ )
15
+
16
+
17
+ class OAKConfig:
18
+ """Configuration for the OAK camera."""
19
+
20
+ color_image_dim: Tuple[int, int] = (640, 480) # RGB camera resolution
21
+ monochrome_image_dim: Tuple[int, int] = (640, 480) # Monochrome camera resolution
22
+ fps: int = 30
23
+ enable_color: bool = True # Enable CAM_A (RGB)
24
+ enable_mono_cameras: bool = False # Enable CAM_B & CAM_C (Monochrome stereo pair)
25
+ mount_position: str = CameraMountPosition.EGO_VIEW.value
26
+
27
+
28
+ class OAKSensor(Sensor, SensorServer):
29
+ """Sensor for the OAK camera family."""
30
+
31
+ def __init__(
32
+ self,
33
+ run_as_server: bool = False,
34
+ port: int = 5555,
35
+ config: OAKConfig = OAKConfig(),
36
+ device_id: Optional[str] = None,
37
+ mount_position: str = CameraMountPosition.EGO_VIEW.value,
38
+ ):
39
+ """Initialize the OAK camera."""
40
+ self.config = config
41
+ self.mount_position = mount_position
42
+ self._run_as_server = run_as_server
43
+
44
+ device_infos = dai.Device.getAllAvailableDevices()
45
+ assert len(device_infos) > 0, f"No OAK devices found for {mount_position}"
46
+ print(f"Device infos: {device_infos}")
47
+ if device_id is not None:
48
+ device_found = False
49
+ for device_info in device_infos:
50
+ if device_info.getDeviceId() == device_id:
51
+ self.device = dai.Device(device_info)
52
+ device_found = True
53
+ break
54
+ if not device_found:
55
+ raise ValueError(f"Device with ID {device_id} not found")
56
+ else:
57
+ self.device = dai.Device()
58
+
59
+ print(f"Connected to OAK device: {self.device.getDeviceName(), self.device.getDeviceId()}")
60
+ print(f"Device ID: {self.device.getDeviceId()}")
61
+
62
+ sockets: list[dai.CameraBoardSocket] = self.device.getConnectedCameras()
63
+ print(f"Available cameras: {[str(s) for s in sockets]}")
64
+
65
+ # Create pipeline (without context manager to persist across method calls)
66
+ self.pipeline = dai.Pipeline(self.device)
67
+ self.output_queues = {}
68
+
69
+ # Configure RGB camera (CAM_A)
70
+ if config.enable_color and dai.CameraBoardSocket.CAM_A in sockets:
71
+ self.cam_rgb = self.pipeline.create(dai.node.Camera)
72
+ cam_socket = dai.CameraBoardSocket.CAM_A
73
+ self.cam_rgb = self.cam_rgb.build(cam_socket)
74
+ # Create RGB output queue
75
+ self.output_queues["color"] = self.cam_rgb.requestOutput(
76
+ config.color_image_dim,
77
+ fps=config.fps,
78
+ ).createOutputQueue()
79
+ print("Enabled CAM_A (RGB)")
80
+
81
+ # Configure Monochrome cameras (CAM_B & CAM_C)
82
+ if config.enable_mono_cameras:
83
+ if dai.CameraBoardSocket.CAM_B in sockets:
84
+ self.cam_mono_left = self.pipeline.create(dai.node.Camera)
85
+ cam_socket = dai.CameraBoardSocket.CAM_B
86
+ self.cam_mono_left = self.cam_mono_left.build(cam_socket)
87
+ # Create mono left output queue
88
+ self.output_queues["mono_left"] = self.cam_mono_left.requestOutput(
89
+ config.monochrome_image_dim,
90
+ fps=config.fps,
91
+ ).createOutputQueue()
92
+ print("Enabled CAM_B (Monochrome Left)")
93
+
94
+ if dai.CameraBoardSocket.CAM_C in sockets:
95
+ self.cam_mono_right = self.pipeline.create(dai.node.Camera)
96
+ cam_socket = dai.CameraBoardSocket.CAM_C
97
+ self.cam_mono_right = self.cam_mono_right.build(cam_socket)
98
+ # Create mono right output queue
99
+ self.output_queues["mono_right"] = self.cam_mono_right.requestOutput(
100
+ config.monochrome_image_dim,
101
+ fps=config.fps,
102
+ ).createOutputQueue()
103
+ print("Enabled CAM_C (Monochrome Right)")
104
+
105
+ assert len(self.output_queues) > 0, "No output queues enabled"
106
+ # auto exposure compensation, for CoRL demo
107
+ # cam_q_in = self.cam_rgb.inputControl.createInputQueue()
108
+ # ctrl = dai.CameraControl()
109
+ # ctrl.setAutoExposureEnable()
110
+ # ctrl.setAutoExposureCompensation(-2)
111
+ # cam_q_in.send(ctrl)
112
+
113
+ # Start pipeline on device
114
+ self.pipeline.start()
115
+
116
+ if run_as_server:
117
+ self.start_server(port)
118
+
119
+ def read(self) -> Optional[Dict[str, Any]]:
120
+ """Read images from the camera."""
121
+ if not self.pipeline.isRunning():
122
+ print(f"[ERROR] OAK pipeline stopped for {self.mount_position}")
123
+ return None
124
+
125
+ # Check if device is still connected
126
+ if not self.device.isPipelineRunning():
127
+ print(f"[ERROR] OAK device disconnected for {self.mount_position}")
128
+ return None
129
+
130
+ timestamps = {}
131
+ images = {}
132
+ rgb_frame_time = None
133
+
134
+ # Get color frame if enabled
135
+ if "color" in self.output_queues:
136
+ try:
137
+ rgb_frame = self.output_queues["color"].get()
138
+ rgb_frame_time = rgb_frame.getTimestamp()
139
+ if rgb_frame is not None:
140
+ images[self.mount_position] = rgb_frame.getCvFrame()[..., ::-1] # BGR to RGB
141
+ timestamps[self.mount_position] = (
142
+ rgb_frame_time - dai.Clock.now()
143
+ ).total_seconds() + time.time()
144
+ except Exception as e:
145
+ print(f"[ERROR] Failed to read color frame from {self.mount_position}: {e}")
146
+ return None
147
+
148
+ # Get mono frames if enabled
149
+ if "mono_left" in self.output_queues:
150
+ try:
151
+ mono_left_frame = self.output_queues["mono_left"].get()
152
+ mono_left_frame_time = mono_left_frame.getTimestamp()
153
+ if mono_left_frame is not None:
154
+ key = f"{self.mount_position}_left_mono"
155
+ images[key] = mono_left_frame.getCvFrame()
156
+ timestamps[key] = (
157
+ mono_left_frame_time - dai.Clock.now()
158
+ ).total_seconds() + time.time()
159
+ except Exception as e:
160
+ print(f"[ERROR] Failed to read mono_left frame from {self.mount_position}: {e}")
161
+ return None
162
+
163
+ if "mono_right" in self.output_queues:
164
+ try:
165
+ mono_right_frame = self.output_queues["mono_right"].get()
166
+ mono_right_frame_time = mono_right_frame.getTimestamp()
167
+ if mono_right_frame is not None:
168
+ key = f"{self.mount_position}_right_mono"
169
+ images[key] = mono_right_frame.getCvFrame()
170
+ timestamps[key] = (
171
+ mono_right_frame_time - dai.Clock.now()
172
+ ).total_seconds() + time.time()
173
+ except Exception as e:
174
+ print(f"[ERROR] Failed to read mono_right frame from {self.mount_position}: {e}")
175
+ return None
176
+
177
+ if (
178
+ rgb_frame_time is not None
179
+ and (rgb_frame_time - dai.Clock.now()).total_seconds() <= -0.2
180
+ ):
181
+ print(
182
+ f"[{self.mount_position}] OAK latency too large: "
183
+ f"{(dai.Clock.now() - rgb_frame_time).total_seconds() * 1000}ms"
184
+ )
185
+
186
+ return {
187
+ "timestamps": timestamps,
188
+ "images": images,
189
+ }
190
+
191
+ def serialize(self, data: Dict[str, Any]) -> Dict[str, Any]:
192
+ """Serialize data using ImageMessageSchema."""
193
+ serialized_msg = ImageMessageSchema(timestamps=data["timestamps"], images=data["images"])
194
+ return serialized_msg.serialize()
195
+
196
+ def observation_space(self) -> gym.Space:
197
+ spaces = {}
198
+
199
+ if self.config.enable_color:
200
+ spaces["color_image"] = gym.spaces.Box(
201
+ low=0,
202
+ high=255,
203
+ shape=(self.config.color_image_dim[1], self.config.color_image_dim[0], 3),
204
+ dtype=np.uint8,
205
+ )
206
+
207
+ if self.config.enable_mono_cameras:
208
+ spaces["mono_left_image"] = gym.spaces.Box(
209
+ low=0,
210
+ high=255,
211
+ shape=(self.config.monochrome_image_dim[1], self.config.monochrome_image_dim[0]),
212
+ dtype=np.uint8,
213
+ )
214
+ spaces["mono_right_image"] = gym.spaces.Box(
215
+ low=0,
216
+ high=255,
217
+ shape=(self.config.monochrome_image_dim[1], self.config.monochrome_image_dim[0]),
218
+ dtype=np.uint8,
219
+ )
220
+
221
+ return gym.spaces.Dict(spaces)
222
+
223
+ def close(self):
224
+ """Close the camera connection."""
225
+ if self._run_as_server:
226
+ self.stop_server()
227
+ if hasattr(self, "pipeline") and self.pipeline.isRunning():
228
+ self.pipeline.stop()
229
+ self.device.close()
230
+
231
+ def run_server(self):
232
+ """Run the server."""
233
+ if not self._run_as_server:
234
+ raise ValueError("This function is only available when run_as_server is True")
235
+
236
+ while True:
237
+ frame = self.read()
238
+ if frame is None:
239
+ continue
240
+
241
+ msg = self.serialize(frame)
242
+ self.send_message({self.mount_position: msg})
243
+
244
+ def __del__(self):
245
+ self.close()
246
+
247
+
248
+ if __name__ == "__main__":
249
+ """Test function for OAK camera."""
250
+
251
+ import argparse
252
+
253
+ parser = argparse.ArgumentParser()
254
+ parser.add_argument("--server", action="store_true", help="Run as server")
255
+ parser.add_argument("--client", action="store_true", help="Run as client")
256
+ parser.add_argument("--host", type=str, default="localhost", help="Server IP address")
257
+ parser.add_argument("--port", type=int, default=5555, help="Port number")
258
+ parser.add_argument("--device-id", type=str, default=None, help="Specific device ID")
259
+ parser.add_argument(
260
+ "--enable-mono", action="store_true", help="Enable monochrome cameras (CAM_B & CAM_C)"
261
+ )
262
+ parser.add_argument("--mount-position", type=str, default="ego_view", help="Mount position")
263
+ parser.add_argument("--show-image", action="store_true", help="Display images")
264
+ args = parser.parse_args()
265
+
266
+ oak_config = OAKConfig()
267
+ if args.enable_mono:
268
+ oak_config.enable_mono_cameras = True
269
+
270
+ if args.server:
271
+ # Run as server
272
+ oak = OAKSensor(
273
+ run_as_server=True,
274
+ port=args.port,
275
+ config=oak_config,
276
+ device_id=args.device_id,
277
+ mount_position=args.mount_position,
278
+ )
279
+ print(f"Starting OAK server on port {args.port}")
280
+ oak.run_server()
281
+
282
+ else:
283
+ # Run standalone
284
+ oak = OAKSensor(run_as_server=False, config=oak_config, device_id=args.device_id)
285
+ print("Running OAK camera in standalone mode")
286
+
287
+ while True:
288
+ frame = oak.read()
289
+ if frame is None:
290
+ print("Waiting for frame...")
291
+ time.sleep(0.5)
292
+ continue
293
+
294
+ if "color_image" in frame:
295
+ print(f"Color image shape: {frame['color_image'].shape}")
296
+ if "mono_left_image" in frame:
297
+ print(f"Mono left image shape: {frame['mono_left_image'].shape}")
298
+ if "mono_right_image" in frame:
299
+ print(f"Mono right image shape: {frame['mono_right_image'].shape}")
300
+ if "depth_image" in frame:
301
+ print(f"Depth image shape: {frame['depth_image'].shape}")
302
+
303
+ if args.show_image:
304
+ if "color_image" in frame:
305
+ cv2.imshow("Color Image", frame["color_image"])
306
+
307
+ if "mono_left_image" in frame:
308
+ cv2.imshow("Mono Left", frame["mono_left_image"])
309
+ if "mono_right_image" in frame:
310
+ cv2.imshow("Mono Right", frame["mono_right_image"])
311
+
312
+ if "depth_image" in frame:
313
+ depth_colormap = cv2.applyColorMap(
314
+ cv2.convertScaleAbs(frame["depth_image"], alpha=0.03), cv2.COLORMAP_JET
315
+ )
316
+ cv2.imshow("Depth Image", depth_colormap)
317
+
318
+ if cv2.waitKey(1) == ord("q"):
319
+ break
320
+
321
+ time.sleep(0.01)
322
+
323
+ cv2.destroyAllWindows()
324
+ oak.close()
GR00T-WholeBodyControl/decoupled_wbc/control/sensor/sensor_server.py ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ from dataclasses import dataclass
3
+ from enum import Enum
4
+ from typing import Any, Dict
5
+
6
+ import cv2
7
+ import msgpack
8
+ import msgpack_numpy as m
9
+ import numpy as np
10
+ import zmq
11
+
12
+
13
+ @dataclass
14
+ class ImageMessageSchema:
15
+ """
16
+ This is a standardized message schema for image data.
17
+ Any camera should use this schema to serialize (send to queue) and
18
+ deserialize (receive from queue) the image data.
19
+
20
+ """
21
+
22
+ timestamps: Dict[str, float]
23
+ """Dictionary of timestamps, keyed by image identifier (e.g., {"ego_view": 123.45})"""
24
+ images: Dict[str, np.ndarray]
25
+ """Dictionary of images, keyed by image identifier (e.g., {"ego_view": array, "ego_view_left_mono": array})"""
26
+
27
+ def serialize(self) -> Dict[str, Any]:
28
+ """Serialize the message for transmission."""
29
+ serialized_msg = {"timestamps": self.timestamps, "images": {}}
30
+ for key, image in self.images.items():
31
+ serialized_msg["images"][key] = ImageUtils.encode_image(image)
32
+ return serialized_msg
33
+
34
+ @staticmethod
35
+ def deserialize(data: Dict[str, Any]) -> "ImageMessageSchema":
36
+ """Deserialize received message data."""
37
+ timestamps = data.get("timestamps", {})
38
+ images = {}
39
+ for key, value in data.get("images", {}).items():
40
+ if isinstance(value, str):
41
+ images[key] = ImageUtils.decode_image(value)
42
+ else:
43
+ images[key] = value
44
+ return ImageMessageSchema(timestamps=timestamps, images=images)
45
+
46
+ def asdict(self) -> Dict[str, Any]:
47
+ """Convert to dictionary format."""
48
+ return {"timestamps": self.timestamps, "images": self.images}
49
+
50
+
51
+ class SensorServer:
52
+ def start_server(self, port: int):
53
+ self.context = zmq.Context()
54
+ self.socket = self.context.socket(zmq.PUB)
55
+ self.socket.setsockopt(zmq.SNDHWM, 20) # high water mark
56
+ self.socket.setsockopt(zmq.LINGER, 0)
57
+ self.socket.bind(f"tcp://*:{port}")
58
+ print(f"Sensor server running at tcp://*:{port}")
59
+
60
+ self.message_sent = 0
61
+ self.message_dropped = 0
62
+
63
+ def stop_server(self):
64
+ self.socket.close()
65
+ self.context.term()
66
+
67
+ def send_message(self, data: Dict[str, Any]):
68
+ try:
69
+ packed = msgpack.packb(data, use_bin_type=True)
70
+ self.socket.send(packed, flags=zmq.NOBLOCK)
71
+ except zmq.Again:
72
+ self.message_dropped += 1
73
+ print(f"[Warning] message dropped: {self.message_dropped}")
74
+ self.message_sent += 1
75
+
76
+ if self.message_sent % 100 == 0:
77
+ print(
78
+ f"[Sensor server] Message sent: {self.message_sent}, message dropped: {self.message_dropped}"
79
+ )
80
+
81
+
82
+ class SensorClient:
83
+ def start_client(self, server_ip: str, port: int):
84
+ self.context = zmq.Context()
85
+ self.socket = self.context.socket(zmq.SUB)
86
+ self.socket.setsockopt_string(zmq.SUBSCRIBE, "")
87
+ self.socket.setsockopt(zmq.CONFLATE, True) # last msg only.
88
+ self.socket.setsockopt(zmq.RCVHWM, 3) # queue size 3 for receive buffer
89
+ self.socket.connect(f"tcp://{server_ip}:{port}")
90
+
91
+ def stop_client(self):
92
+ self.socket.close()
93
+ self.context.term()
94
+
95
+ def receive_message(self):
96
+ packed = self.socket.recv()
97
+ return msgpack.unpackb(packed, object_hook=m.decode)
98
+
99
+
100
+ class CameraMountPosition(Enum):
101
+ EGO_VIEW = "ego_view"
102
+ HEAD = "head"
103
+ LEFT_WRIST = "left_wrist"
104
+ RIGHT_WRIST = "right_wrist"
105
+
106
+
107
+ class ImageUtils:
108
+ @staticmethod
109
+ def encode_image(image: np.ndarray) -> str:
110
+ _, color_buffer = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
111
+ return base64.b64encode(color_buffer).decode("utf-8")
112
+
113
+ @staticmethod
114
+ def encode_depth_image(image: np.ndarray) -> str:
115
+ depth_compressed = cv2.imencode(".png", image)[1].tobytes()
116
+ return base64.b64encode(depth_compressed).decode("utf-8")
117
+
118
+ @staticmethod
119
+ def decode_image(image: str) -> np.ndarray:
120
+ color_data = base64.b64decode(image)
121
+ color_array = np.frombuffer(color_data, dtype=np.uint8)
122
+ return cv2.imdecode(color_array, cv2.IMREAD_COLOR)
123
+
124
+ @staticmethod
125
+ def decode_depth_image(image: str) -> np.ndarray:
126
+ depth_data = base64.b64decode(image)
127
+ depth_array = np.frombuffer(depth_data, dtype=np.uint8)
128
+ return cv2.imdecode(depth_array, cv2.IMREAD_UNCHANGED)
GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_retargeting_ik.py ADDED
@@ -0,0 +1,148 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from typing import List, Optional
3
+
4
+ import numpy as np
5
+
6
+ from decoupled_wbc.control.base.policy import Policy
7
+ from decoupled_wbc.control.robot_model.robot_model import ReducedRobotModel, RobotModel
8
+ from decoupled_wbc.control.teleop.solver.body.body_ik_solver import BodyIKSolver
9
+ from decoupled_wbc.control.teleop.solver.body.body_ik_solver_settings import BodyIKSolverSettings
10
+ from decoupled_wbc.control.teleop.solver.solver import Solver
11
+ from decoupled_wbc.control.visualization.humanoid_visualizer import RobotVisualizer
12
+
13
+
14
+ class TeleopRetargetingIK(Policy):
15
+ """
16
+ Robot-agnostic teleop retargeting inverse kinematics code.
17
+ Focus only on IK processing, ignore commands.
18
+ """
19
+
20
+ def __init__(
21
+ self,
22
+ robot_model: RobotModel,
23
+ left_hand_ik_solver: Solver,
24
+ right_hand_ik_solver: Solver,
25
+ enable_visualization=False,
26
+ body_active_joint_groups: Optional[List[str]] = None,
27
+ body_ik_solver_settings_type: str = "default",
28
+ ):
29
+ # initialize the body
30
+ if body_active_joint_groups is not None:
31
+ self.body = ReducedRobotModel.from_active_groups(robot_model, body_active_joint_groups)
32
+ self.full_robot = self.body.full_robot
33
+ self.using_reduced_robot_model = True
34
+ else:
35
+ self.body = robot_model
36
+ self.full_robot = self.body
37
+ self.using_reduced_robot_model = False
38
+ if body_ik_solver_settings_type == "default":
39
+ body_ik_solver_settings = BodyIKSolverSettings()
40
+ else:
41
+ raise ValueError(
42
+ f"Unknown body_ik_solver_settings_type: {body_ik_solver_settings_type}"
43
+ )
44
+ self.body_ik_solver = BodyIKSolver(body_ik_solver_settings)
45
+
46
+ # We register the specific robot model to the robot-agnostic body IK solver class
47
+ self.body_ik_solver.register_robot(self.body)
48
+
49
+ # Hand IK solvers are hand specific, so we pass them in the constructor
50
+ self.left_hand_ik_solver = left_hand_ik_solver
51
+ self.right_hand_ik_solver = right_hand_ik_solver
52
+
53
+ # enable visualizer
54
+ self.enable_visualization = enable_visualization
55
+ if self.enable_visualization:
56
+ self.visualizer = RobotVisualizer(self.full_robot)
57
+ self.visualizer.visualize(self.full_robot.q_zero)
58
+ time.sleep(1) # wait for the visualizer to start
59
+
60
+ self.in_warmup = True
61
+ self._most_recent_ik_data = None
62
+ self._most_recent_q = self.full_robot.default_body_pose.copy()
63
+
64
+ def compute_joint_positions(
65
+ self, body_data: dict, left_hand_data: dict, right_hand_data: dict
66
+ ) -> np.ndarray:
67
+ """Process only IK-related data, return joint positions"""
68
+ if self.in_warmup:
69
+ # TODO: Warmup is not necessary if we start IK from the current robot qpos, rather than the zero qpos
70
+ for _ in range(50):
71
+ target_robot_joints = self._inverse_kinematics(
72
+ body_data, left_hand_data, right_hand_data
73
+ )
74
+ self.in_warmup = False
75
+ else:
76
+ target_robot_joints = self._inverse_kinematics(
77
+ body_data, left_hand_data, right_hand_data
78
+ )
79
+
80
+ return target_robot_joints
81
+
82
+ def _inverse_kinematics(
83
+ self,
84
+ body_target_pose,
85
+ left_hand_target_pose,
86
+ right_hand_target_pose,
87
+ ):
88
+ """
89
+ Solve the inverse kinematics problem for the given target poses.
90
+ Args:
91
+ body_target_pose: Dictionary of link names and their corresponding target pose.
92
+ left_hand_target_pose: Dictionary with key "position" mapping to a (25, 4, 4) np.ndarray from AVP data
93
+ right_hand_target_pose: Dictionary with key "position" mapping to a (25, 4, 4) np.ndarray from AVP data
94
+ q: Initial configuration vector.
95
+ Returns:
96
+ Configuration vector that achieves the target poses.
97
+ """
98
+ if body_target_pose:
99
+ if self.using_reduced_robot_model:
100
+ body_q = self.body.reduced_to_full_configuration(
101
+ self.body_ik_solver(body_target_pose)
102
+ )
103
+ else:
104
+ body_q = self.body_ik_solver(body_target_pose)
105
+ else:
106
+ # If no body target pose is provided, set the body to the default pose
107
+ body_q = self.full_robot.default_body_pose.copy()
108
+
109
+ if left_hand_target_pose is not None:
110
+ left_hand_actuated_q = self.left_hand_ik_solver(left_hand_target_pose)
111
+ body_q[self.full_robot.get_hand_actuated_joint_indices(side="left")] = (
112
+ left_hand_actuated_q
113
+ )
114
+
115
+ if right_hand_target_pose is not None:
116
+ right_hand_actuated_q = self.right_hand_ik_solver(right_hand_target_pose)
117
+ body_q[self.full_robot.get_hand_actuated_joint_indices(side="right")] = (
118
+ right_hand_actuated_q
119
+ )
120
+
121
+ if self.enable_visualization:
122
+ self.visualizer.visualize(np.array(body_q))
123
+
124
+ return body_q
125
+
126
+ def reset(self):
127
+ """Reset the robot model and IK solvers to the initial state, and re-activate the warmup procedure."""
128
+ self.body.reset_forward_kinematics() # self.body is the same one as self.body_ik_solver.robot
129
+ self.full_robot.reset_forward_kinematics()
130
+ self.body_ik_solver.initialize()
131
+ # If in the future, the hand IK solver has initialize method, call it
132
+ self._most_recent_ik_data = None
133
+ self._most_recent_q = self.full_robot.default_body_pose.copy()
134
+ self.in_warmup = True
135
+
136
+ def set_goal(self, ik_data: dict):
137
+ self._most_recent_ik_data = ik_data
138
+
139
+ def get_action(self) -> dict[str, any]:
140
+ # Process IK if active
141
+ if self._most_recent_ik_data is not None:
142
+ body_data = self._most_recent_ik_data["body_data"]
143
+ left_hand_data = self._most_recent_ik_data["left_hand_data"]
144
+ right_hand_data = self._most_recent_ik_data["right_hand_data"]
145
+ target_joints = self.compute_joint_positions(body_data, left_hand_data, right_hand_data)
146
+ self._most_recent_q = target_joints
147
+
148
+ return self._most_recent_q[self.full_robot.get_joint_group_indices("upper_body")]
GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_streamer.py ADDED
@@ -0,0 +1,240 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from math import floor
2
+ import pickle
3
+ from typing import Optional
4
+
5
+ from decoupled_wbc.control.robot_model.robot_model import RobotModel
6
+ from decoupled_wbc.control.teleop.pre_processor.fingers.fingers import FingersPreProcessor
7
+ from decoupled_wbc.control.teleop.pre_processor.wrists.wrists import WristsPreProcessor
8
+ from decoupled_wbc.control.teleop.streamers.base_streamer import StreamerOutput
9
+
10
+
11
+ class TeleopStreamer:
12
+ def __init__(
13
+ self,
14
+ robot_model: RobotModel,
15
+ body_control_device: Optional[str] = None,
16
+ hand_control_device: Optional[str] = None,
17
+ enable_real_device=True,
18
+ body_streamer_ip="",
19
+ body_streamer_keyword="",
20
+ replay_data_path: Optional[str] = None,
21
+ replay_speed: float = 1.0,
22
+ ):
23
+ # initialize the body
24
+ self.body = robot_model
25
+
26
+ self.body_control_device = body_control_device
27
+ self.hand_control_device = hand_control_device
28
+ self.body_streamer_ip = body_streamer_ip
29
+ self.body_streamer_keyword = body_streamer_keyword
30
+ self.replay_speed = replay_speed
31
+
32
+ # enable real robot and devices
33
+ self.enable_real_device = enable_real_device
34
+ if self.enable_real_device:
35
+ if body_control_device == "vive":
36
+ from decoupled_wbc.control.teleop.streamers.vive_streamer import ViveStreamer
37
+
38
+ self.body_streamer = ViveStreamer(
39
+ ip=self.body_streamer_ip, keyword=self.body_streamer_keyword
40
+ )
41
+ self.body_streamer.start_streaming()
42
+ elif body_control_device == "iphone":
43
+ from decoupled_wbc.control.teleop.streamers.iphone_streamer import IphoneStreamer
44
+
45
+ self.body_streamer = IphoneStreamer()
46
+ self.body_streamer.start_streaming()
47
+ elif body_control_device == "leapmotion":
48
+ from decoupled_wbc.control.teleop.streamers.leapmotion_streamer import (
49
+ LeapMotionStreamer,
50
+ )
51
+
52
+ self.body_streamer = LeapMotionStreamer()
53
+ self.body_streamer.start_streaming()
54
+ elif body_control_device == "joycon":
55
+ from decoupled_wbc.control.teleop.streamers.joycon_streamer import JoyconStreamer
56
+
57
+ self.body_streamer = JoyconStreamer()
58
+ self.body_streamer.start_streaming()
59
+
60
+ elif body_control_device == "pico":
61
+ from decoupled_wbc.control.teleop.streamers.pico_streamer import PicoStreamer
62
+
63
+ self.body_streamer = PicoStreamer()
64
+ self.body_streamer.start_streaming()
65
+ elif body_control_device == "dummy":
66
+ from decoupled_wbc.control.teleop.streamers.dummy_streamer import DummyStreamer
67
+
68
+ self.body_streamer = DummyStreamer()
69
+ self.body_streamer.start_streaming()
70
+ else:
71
+ self.body_streamer = None
72
+
73
+ if hand_control_device and hand_control_device != body_control_device:
74
+ if hand_control_device == "manus":
75
+ from decoupled_wbc.control.teleop.streamers.manus_streamer import ManusStreamer
76
+
77
+ self.hand_streamer = ManusStreamer()
78
+ self.hand_streamer.start_streaming()
79
+ elif hand_control_device == "joycon":
80
+ from decoupled_wbc.control.teleop.streamers.joycon_streamer import JoyconStreamer
81
+
82
+ self.hand_streamer = JoyconStreamer()
83
+ self.hand_streamer.start_streaming()
84
+ elif hand_control_device == "iphone":
85
+ from decoupled_wbc.control.teleop.streamers.iphone_streamer import IphoneStreamer
86
+
87
+ self.hand_streamer = IphoneStreamer()
88
+ self.hand_streamer.start_streaming()
89
+ elif hand_control_device == "pico":
90
+ from decoupled_wbc.control.teleop.streamers.pico_streamer import PicoStreamer
91
+
92
+ self.hand_streamer = PicoStreamer()
93
+ self.hand_streamer.start_streaming()
94
+ else:
95
+ self.hand_streamer = None
96
+ else:
97
+ self.hand_streamer = None
98
+ else:
99
+ self.body_streamer = None
100
+ self.hand_streamer = None
101
+
102
+ self.raw_replay_data = None
103
+ self.replay_calibration_data = None
104
+ self.replay_mode = False
105
+ if replay_data_path and not self.enable_real_device:
106
+ with open(replay_data_path, "rb") as f:
107
+ data_ = pickle.load(f)
108
+ self.raw_replay_data = data_["replay_data"]
109
+ self.replay_calibration_data = data_["calibration_data"]
110
+ print("Found teleop replay data in file: ", replay_data_path)
111
+ self.replay_idx = 0
112
+ self.replay_mode = True
113
+
114
+ # initialize pre_processors
115
+ self.body_control_device = body_control_device
116
+ if body_control_device or self.replay_mode:
117
+ self.body_pre_processor = WristsPreProcessor(
118
+ motion_scale=robot_model.supplemental_info.teleop_upper_body_motion_scale
119
+ )
120
+ self.body_pre_processor.register(self.body)
121
+ else:
122
+ self.body_pre_processor = None
123
+
124
+ # initialize hand pre-processors and post-processors
125
+ self.hand_control_device = hand_control_device
126
+ if hand_control_device or self.replay_mode:
127
+ self.left_hand_pre_processor = FingersPreProcessor(side="left")
128
+ self.right_hand_pre_processor = FingersPreProcessor(side="right")
129
+
130
+ else:
131
+ self.left_hand_pre_processor = None
132
+ self.right_hand_pre_processor = None
133
+
134
+ self.is_calibrated = False
135
+
136
+ def _get_replay_data(self) -> StreamerOutput:
137
+ streamer_data = StreamerOutput()
138
+
139
+ if self.replay_idx < len(self.raw_replay_data):
140
+ streamer_data.ik_data.update(
141
+ self.raw_replay_data[floor(self.replay_idx / self.replay_speed)]
142
+ )
143
+ self.replay_idx += 1
144
+
145
+ return streamer_data
146
+
147
+ def _get_live_data(self) -> StreamerOutput:
148
+ """Get structured data instead of raw dict"""
149
+ if self.body_streamer:
150
+ streamer_data = self.body_streamer.get()
151
+ else:
152
+ streamer_data = StreamerOutput()
153
+
154
+ if self.hand_streamer and self.hand_streamer != self.body_streamer:
155
+ hand_data = self.hand_streamer.get()
156
+
157
+ # Merge hand data into body data (hand data takes precedence)
158
+ streamer_data.ik_data.update(hand_data.ik_data)
159
+ streamer_data.control_data.update(hand_data.control_data)
160
+ streamer_data.teleop_data.update(hand_data.teleop_data)
161
+ streamer_data.data_collection_data.update(hand_data.data_collection_data)
162
+
163
+ return streamer_data
164
+
165
+ def get_streamer_data(self) -> StreamerOutput:
166
+ if self.enable_real_device:
167
+ streamer_data = self._get_live_data()
168
+ elif self.replay_mode:
169
+ streamer_data = self._get_replay_data()
170
+ else:
171
+ streamer_data = StreamerOutput()
172
+
173
+ if self.is_calibrated and streamer_data.ik_data:
174
+ body_data, left_hand_data, right_hand_data = self.pre_process(streamer_data.ik_data)
175
+ streamer_data.ik_data = {
176
+ "body_data": body_data,
177
+ "left_hand_data": left_hand_data,
178
+ "right_hand_data": right_hand_data,
179
+ }
180
+ elif not self.is_calibrated:
181
+ streamer_data.ik_data = {}
182
+
183
+ return streamer_data
184
+
185
+ def calibrate(self):
186
+ """Calibrate the pre-processors using only IK data."""
187
+ if self.replay_mode:
188
+ ik_data = self.replay_calibration_data
189
+ else:
190
+ streamer_data = self._get_live_data()
191
+ ik_data = streamer_data.ik_data
192
+
193
+ if self.body_pre_processor:
194
+ self.body_pre_processor.calibrate(ik_data, self.body_control_device)
195
+ if self.left_hand_pre_processor:
196
+ self.left_hand_pre_processor.calibrate(ik_data, self.hand_control_device)
197
+ if self.right_hand_pre_processor:
198
+ self.right_hand_pre_processor.calibrate(ik_data, self.hand_control_device)
199
+
200
+ self.is_calibrated = True
201
+
202
+ def pre_process(self, raw_data):
203
+ """Pre-process the raw data."""
204
+ assert (
205
+ self.body_pre_processor or self.left_hand_pre_processor or self.right_hand_pre_processor
206
+ ), "Pre-processors are not initialized."
207
+
208
+ # Check if finger data is present in raw_data
209
+ has_finger_data = "left_fingers" in raw_data and "right_fingers" in raw_data
210
+
211
+ if self.body_pre_processor:
212
+ body_data = self.body_pre_processor(raw_data)
213
+ # Only process hand data if finger keys are present and preprocessors are available
214
+ if has_finger_data and self.left_hand_pre_processor and self.right_hand_pre_processor:
215
+ left_hand_data = self.left_hand_pre_processor(raw_data)
216
+ right_hand_data = self.right_hand_pre_processor(raw_data)
217
+ return body_data, left_hand_data, right_hand_data
218
+ else:
219
+ return body_data, None, None
220
+ else: # only hands
221
+ if has_finger_data and self.left_hand_pre_processor and self.right_hand_pre_processor:
222
+ left_hand_data = self.left_hand_pre_processor(raw_data)
223
+ right_hand_data = self.right_hand_pre_processor(raw_data)
224
+ return None, left_hand_data, right_hand_data
225
+ else:
226
+ # No finger data available, return None for hand data
227
+ return None, None, None
228
+
229
+ def reset(self):
230
+ if self.body_streamer is not None:
231
+ self.body_streamer.reset_status()
232
+ if self.hand_streamer is not None:
233
+ self.hand_streamer.reset_status()
234
+
235
+ def stop_streaming(self):
236
+ if self.body_streamer:
237
+ self.body_streamer.stop_streaming()
238
+ # Only stop hand_streamer if it's a different instance than body_streamer
239
+ if self.hand_streamer and self.hand_streamer is not self.body_streamer:
240
+ self.hand_streamer.stop_streaming()
GR00T-WholeBodyControl/decoupled_wbc/control/utils/__init__.py ADDED
File without changes
GR00T-WholeBodyControl/decoupled_wbc/control/utils/cv_bridge.py ADDED
@@ -0,0 +1,396 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Software License Agreement (BSD License)
2
+ #
3
+ # Copyright (c) 2011, Willow Garage, Inc.
4
+ # Copyright (c) 2016, Tal Regev.
5
+ # All rights reserved.
6
+ #
7
+ # Redistribution and use in source and binary forms, with or without
8
+ # modification, are permitted provided that the following conditions
9
+ # are met:
10
+ #
11
+ # * Redistributions of source code must retain the above copyright
12
+ # notice, this list of conditions and the following disclaimer.
13
+ # * Redistributions in binary form must reproduce the above
14
+ # copyright notice, this list of conditions and the following
15
+ # disclaimer in the documentation and/or other materials provided
16
+ # with the distribution.
17
+ # * Neither the name of Willow Garage, Inc. nor the names of its
18
+ # contributors may be used to endorse or promote products derived
19
+ # from this software without specific prior written permission.
20
+ #
21
+ # THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
22
+ # "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
23
+ # LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
24
+ # FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
25
+ # COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
26
+ # INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
27
+ # BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
28
+ # LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
29
+ # CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
30
+ # LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
31
+ # ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
32
+ # POSSIBILITY OF SUCH DAMAGE.
33
+
34
+ import sys
35
+
36
+ import cv2
37
+ import sensor_msgs.msg
38
+
39
+
40
+ def CV_MAT_CNWrap(flags):
41
+ return (((flags) & ((63) << 3)) >> 3) + 1
42
+
43
+
44
+ def CV_MAT_DEPTHWrap(flags):
45
+ return (flags) & 7
46
+
47
+
48
+ _CV_CONVERSIONS = {
49
+ ("mono8", "rgb8"): cv2.COLOR_GRAY2RGB,
50
+ ("mono8", "bgr8"): cv2.COLOR_GRAY2BGR,
51
+ ("mono8", "rgba8"): cv2.COLOR_GRAY2RGBA,
52
+ ("mono8", "bgra8"): cv2.COLOR_GRAY2BGRA,
53
+ ("rgb8", "mono8"): cv2.COLOR_RGB2GRAY,
54
+ ("rgb8", "bgr8"): cv2.COLOR_RGB2BGR,
55
+ ("rgb8", "rgba8"): cv2.COLOR_RGB2RGBA,
56
+ ("rgb8", "bgra8"): cv2.COLOR_RGB2BGRA,
57
+ ("bgr8", "mono8"): cv2.COLOR_BGR2GRAY,
58
+ ("bgr8", "rgb8"): cv2.COLOR_BGR2RGB,
59
+ ("bgr8", "rgba8"): cv2.COLOR_BGR2RGBA,
60
+ ("bgr8", "bgra8"): cv2.COLOR_BGR2BGRA,
61
+ ("rgba8", "mono8"): cv2.COLOR_RGBA2GRAY,
62
+ ("rgba8", "rgb8"): cv2.COLOR_RGBA2RGB,
63
+ ("rgba8", "bgr8"): cv2.COLOR_RGBA2BGR,
64
+ ("rgba8", "bgra8"): cv2.COLOR_RGBA2BGRA,
65
+ ("bgra8", "mono8"): cv2.COLOR_BGRA2GRAY,
66
+ ("bgra8", "rgb8"): cv2.COLOR_BGRA2RGB,
67
+ ("bgra8", "bgr8"): cv2.COLOR_BGRA2BGR,
68
+ ("bgra8", "rgba8"): cv2.COLOR_BGRA2RGBA,
69
+ ("yuv422", "mono8"): cv2.COLOR_YUV2GRAY_UYVY,
70
+ ("yuv422", "rgb8"): cv2.COLOR_YUV2RGB_UYVY,
71
+ ("yuv422", "bgr8"): cv2.COLOR_YUV2BGR_UYVY,
72
+ ("yuv422", "rgba8"): cv2.COLOR_YUV2RGBA_UYVY,
73
+ ("yuv422", "bgra8"): cv2.COLOR_YUV2BGRA_UYVY,
74
+ ("bayer_rggb8", "mono8"): cv2.COLOR_BayerBG2GRAY,
75
+ ("bayer_rggb8", "rgb8"): cv2.COLOR_BayerBG2RGB,
76
+ ("bayer_rggb8", "bgr8"): cv2.COLOR_BayerBG2BGR,
77
+ ("bayer_bggr8", "mono8"): cv2.COLOR_BayerRG2GRAY,
78
+ ("bayer_bggr8", "rgb8"): cv2.COLOR_BayerRG2RGB,
79
+ ("bayer_bggr8", "bgr8"): cv2.COLOR_BayerRG2BGR,
80
+ ("bayer_gbrg8", "mono8"): cv2.COLOR_BayerGR2GRAY,
81
+ ("bayer_gbrg8", "rgb8"): cv2.COLOR_BayerGR2RGB,
82
+ ("bayer_gbrg8", "bgr8"): cv2.COLOR_BayerGR2BGR,
83
+ ("bayer_grbg", "mono8"): cv2.COLOR_BayerGB2GRAY,
84
+ ("bayer_grbg", "rgb8"): cv2.COLOR_BayerGB2RGB,
85
+ ("bayer_grbg", "bgr8"): cv2.COLOR_BayerGB2BGR,
86
+ }
87
+
88
+ _CV_TYPES = {
89
+ "rgb8": cv2.CV_8UC3,
90
+ "rgba8": cv2.CV_8UC4,
91
+ "rgb16": cv2.CV_16UC3,
92
+ "rgba16": cv2.CV_16UC4,
93
+ "bgr8": cv2.CV_8UC3,
94
+ "bgra8": cv2.CV_8UC4,
95
+ "bgr16": cv2.CV_16UC3,
96
+ "bgra16": cv2.CV_16UC4,
97
+ "mono8": cv2.CV_8UC1,
98
+ "mono16": cv2.CV_16UC1,
99
+ "8UC1": cv2.CV_8UC1,
100
+ "8UC2": cv2.CV_8UC2,
101
+ "8UC3": cv2.CV_8UC3,
102
+ "8UC4": cv2.CV_8UC4,
103
+ "8SC1": cv2.CV_8SC1,
104
+ "8SC2": cv2.CV_8SC2,
105
+ "8SC3": cv2.CV_8SC3,
106
+ "8SC4": cv2.CV_8SC4,
107
+ "16UC1": cv2.CV_8UC1,
108
+ "16UC2": cv2.CV_8UC2,
109
+ "16UC3": cv2.CV_8UC3,
110
+ "16UC4": cv2.CV_8UC4,
111
+ "16SC1": cv2.CV_16SC1,
112
+ "16SC2": cv2.CV_16SC2,
113
+ "16SC3": cv2.CV_16SC3,
114
+ "16SC4": cv2.CV_16SC4,
115
+ "32SC1": cv2.CV_32SC1,
116
+ "32SC2": cv2.CV_32SC2,
117
+ "32SC3": cv2.CV_32SC3,
118
+ "32SC4": cv2.CV_32SC4,
119
+ "32FC1": cv2.CV_32FC1,
120
+ "32FC2": cv2.CV_32FC2,
121
+ "32FC3": cv2.CV_32FC3,
122
+ "32FC4": cv2.CV_32FC4,
123
+ "64FC1": cv2.CV_64FC1,
124
+ "64FC2": cv2.CV_64FC2,
125
+ "64FC3": cv2.CV_64FC3,
126
+ "64FC4": cv2.CV_64FC4,
127
+ "bayer_rggb8": cv2.CV_8UC1,
128
+ "bayer_bggr8": cv2.CV_8UC1,
129
+ "bayer_gbrg8": cv2.CV_8UC1,
130
+ "bayer_grbg8": cv2.CV_8UC1,
131
+ "bayer_rggb16": cv2.CV_16UC1,
132
+ "bayer_bggr16": cv2.CV_16UC1,
133
+ "bayer_gbrg16": cv2.CV_16UC1,
134
+ "bayer_grbg16": cv2.CV_16UC1,
135
+ }
136
+
137
+
138
+ def cvtColor2(img, encoding_in, encoding_out):
139
+ if encoding_in == encoding_out:
140
+ return img
141
+
142
+ conversion = _CV_CONVERSIONS[(encoding_in, encoding_out)]
143
+ # depth conversion is not yet implemented
144
+ return cv2.cvtColor(img, conversion)
145
+
146
+
147
+ def getCvType(encoding):
148
+ return _CV_TYPES[encoding]
149
+
150
+
151
+ class CvBridgeError(TypeError):
152
+ """
153
+ This is the error raised by :class:`cv_bridge.CvBridge` methods when they fail.
154
+ """
155
+
156
+ pass
157
+
158
+
159
+ class CvBridge(object):
160
+ """
161
+ The CvBridge is an object that converts between OpenCV Images and ROS Image messages.
162
+
163
+ .. doctest::
164
+ :options: -ELLIPSIS, +NORMALIZE_WHITESPACE
165
+
166
+ >>> import cv2
167
+ >>> import numpy as np
168
+ >>> from cv_bridge import CvBridge
169
+ >>> br = CvBridge()
170
+ >>> dtype, n_channels = br.encoding_as_cvtype2('8UC3')
171
+ >>> im = np.ndarray(shape=(480, 640, n_channels), dtype=dtype)
172
+ >>> msg = br.cv2_to_imgmsg(im) # Convert the image to a message
173
+ >>> im2 = br.imgmsg_to_cv2(msg) # Convert the message to a new image
174
+ >>> cmprsmsg = br.cv2_to_compressed_imgmsg(im) # Convert the image to a compress message
175
+ >>> im22 = br.compressed_imgmsg_to_cv2(msg) # Convert the compress message to a new image
176
+ >>> cv2.imwrite("this_was_a_message_briefly.png", im2)
177
+
178
+ """
179
+
180
+ def __init__(self):
181
+ import cv2
182
+
183
+ self.cvtype_to_name = {}
184
+ self.cvdepth_to_numpy_depth = {
185
+ cv2.CV_8U: "uint8",
186
+ cv2.CV_8S: "int8",
187
+ cv2.CV_16U: "uint16",
188
+ cv2.CV_16S: "int16",
189
+ cv2.CV_32S: "int32",
190
+ cv2.CV_32F: "float32",
191
+ cv2.CV_64F: "float64",
192
+ }
193
+
194
+ for t in ["8U", "8S", "16U", "16S", "32S", "32F", "64F"]:
195
+ for c in [1, 2, 3, 4]:
196
+ nm = "%sC%d" % (t, c)
197
+ self.cvtype_to_name[getattr(cv2, "CV_%s" % nm)] = nm
198
+
199
+ self.numpy_type_to_cvtype = {
200
+ "uint8": "8U",
201
+ "int8": "8S",
202
+ "uint16": "16U",
203
+ "int16": "16S",
204
+ "int32": "32S",
205
+ "float32": "32F",
206
+ "float64": "64F",
207
+ }
208
+ self.numpy_type_to_cvtype.update(
209
+ dict((v, k) for (k, v) in self.numpy_type_to_cvtype.items())
210
+ )
211
+
212
+ def dtype_with_channels_to_cvtype2(self, dtype, n_channels):
213
+ return "%sC%d" % (self.numpy_type_to_cvtype[dtype.name], n_channels)
214
+
215
+ def cvtype2_to_dtype_with_channels(self, cvtype):
216
+ return self.cvdepth_to_numpy_depth[CV_MAT_DEPTHWrap(cvtype)], CV_MAT_CNWrap(cvtype)
217
+
218
+ def encoding_to_cvtype2(self, encoding):
219
+ try:
220
+ return getCvType(encoding)
221
+ except RuntimeError as e:
222
+ raise CvBridgeError(e)
223
+
224
+ def encoding_to_dtype_with_channels(self, encoding):
225
+ return self.cvtype2_to_dtype_with_channels(self.encoding_to_cvtype2(encoding))
226
+
227
+ def compressed_imgmsg_to_cv2(self, cmprs_img_msg, desired_encoding="passthrough"):
228
+ """
229
+ Convert a sensor_msgs::CompressedImage message to an OpenCV :cpp:type:`cv::Mat`.
230
+
231
+ :param cmprs_img_msg: A :cpp:type:`sensor_msgs::CompressedImage` message
232
+ :param desired_encoding: The encoding of the image data, one of the following strings:
233
+
234
+ * ``"passthrough"``
235
+ * one of the standard strings in sensor_msgs/image_encodings.h
236
+
237
+ :rtype: :cpp:type:`cv::Mat`
238
+ :raises CvBridgeError: when conversion is not possible.
239
+
240
+ If desired_encoding is ``"passthrough"``, then the returned image has the same format as img_msg.
241
+ Otherwise desired_encoding must be one of the standard image encodings
242
+
243
+ This function returns an OpenCV :cpp:type:`cv::Mat` message on success, or raises
244
+ :exc:`cv_bridge.CvBridgeError` on failure.
245
+
246
+ If the image only has one channel, the shape has size 2 (width and height)
247
+ """
248
+ import cv2
249
+ import numpy as np
250
+
251
+ str_msg = cmprs_img_msg.data
252
+ buf = np.ndarray(shape=(1, len(str_msg)), dtype=np.uint8, buffer=cmprs_img_msg.data)
253
+ im = cv2.imdecode(buf, cv2.IMREAD_ANYCOLOR)
254
+
255
+ if desired_encoding == "passthrough":
256
+ return im
257
+
258
+ try:
259
+ res = cvtColor2(im, "bgr8", desired_encoding)
260
+ except RuntimeError as e:
261
+ raise CvBridgeError(e)
262
+
263
+ return res
264
+
265
+ def imgmsg_to_cv2(self, img_msg, desired_encoding="passthrough"):
266
+ """
267
+ Convert a sensor_msgs::Image message to an OpenCV :cpp:type:`cv::Mat`.
268
+
269
+ :param img_msg: A :cpp:type:`sensor_msgs::Image` message
270
+ :param desired_encoding: The encoding of the image data, one of the following strings:
271
+
272
+ * ``"passthrough"``
273
+ * one of the standard strings in sensor_msgs/image_encodings.h
274
+
275
+ :rtype: :cpp:type:`cv::Mat`
276
+ :raises CvBridgeError: when conversion is not possible.
277
+
278
+ If desired_encoding is ``"passthrough"``, then the returned image has the same format as img_msg.
279
+ Otherwise desired_encoding must be one of the standard image encodings
280
+
281
+ This function returns an OpenCV :cpp:type:`cv::Mat` message on success, or raises
282
+ :exc:`cv_bridge.CvBridgeError` on failure.
283
+
284
+ If the image only has one channel, the shape has size 2 (width and height)
285
+ """
286
+ import numpy as np
287
+
288
+ dtype, n_channels = self.encoding_to_dtype_with_channels(img_msg.encoding)
289
+ dtype = np.dtype(dtype)
290
+ dtype = dtype.newbyteorder(">" if img_msg.is_bigendian else "<")
291
+ if n_channels == 1:
292
+ im = np.ndarray(shape=(img_msg.height, img_msg.width), dtype=dtype, buffer=img_msg.data)
293
+ else:
294
+ im = np.ndarray(
295
+ shape=(img_msg.height, img_msg.width, n_channels), dtype=dtype, buffer=img_msg.data
296
+ )
297
+ # If the byt order is different between the message and the system.
298
+ if img_msg.is_bigendian == (sys.byteorder == "little"):
299
+ im = im.byteswap().newbyteorder()
300
+
301
+ if desired_encoding == "passthrough":
302
+ return im
303
+
304
+ try:
305
+ res = cvtColor2(im, img_msg.encoding, desired_encoding)
306
+ except RuntimeError as e:
307
+ raise CvBridgeError(e)
308
+
309
+ return res
310
+
311
+ def cv2_to_compressed_imgmsg(self, cvim, dst_format="jpg"):
312
+ """
313
+ Convert an OpenCV :cpp:type:`cv::Mat` type to a ROS sensor_msgs::CompressedImage message.
314
+
315
+ :param cvim: An OpenCV :cpp:type:`cv::Mat`
316
+ :param dst_format: The format of the image data, one of the following strings:
317
+
318
+ * from http://docs.opencv.org/2.4/modules/highgui/doc/reading_and_writing_images_and_video.html
319
+ * from http://docs.opencv.org/2.4/modules/highgui/doc/reading_and_writing_images_and_video.html#Mat
320
+ imread(const string& filename, int flags)
321
+ * bmp, dib
322
+ * jpeg, jpg, jpe
323
+ * jp2
324
+ * png
325
+ * pbm, pgm, ppm
326
+ * sr, ras
327
+ * tiff, tif
328
+
329
+ :rtype: A sensor_msgs.msg.CompressedImage message
330
+ :raises CvBridgeError: when the ``cvim`` has a type that is incompatible with ``format``
331
+
332
+
333
+ This function returns a sensor_msgs::Image message on success, or raises
334
+ :exc:`cv_bridge.CvBridgeError` on failure.
335
+ """
336
+ import cv2
337
+ import numpy as np
338
+
339
+ if not isinstance(cvim, (np.ndarray, np.generic)):
340
+ raise TypeError("Your input type is not a numpy array")
341
+ cmprs_img_msg = sensor_msgs.msg.CompressedImage()
342
+ cmprs_img_msg.format = dst_format
343
+ ext_format = "." + dst_format
344
+ try:
345
+ cmprs_img_msg.data = np.array(cv2.imencode(ext_format, cvim)[1]).tostring()
346
+ except RuntimeError as e:
347
+ raise CvBridgeError(e)
348
+
349
+ return cmprs_img_msg
350
+
351
+ def cv2_to_imgmsg(self, cvim, encoding="passthrough"):
352
+ """
353
+ Convert an OpenCV :cpp:type:`cv::Mat` type to a ROS sensor_msgs::Image message.
354
+
355
+ :param cvim: An OpenCV :cpp:type:`cv::Mat`
356
+ :param encoding: The encoding of the image data, one of the following strings:
357
+
358
+ * ``"passthrough"``
359
+ * one of the standard strings in sensor_msgs/image_encodings.h
360
+
361
+ :rtype: A sensor_msgs.msg.Image message
362
+ :raises CvBridgeError: when the ``cvim`` has a type that is incompatible with ``encoding``
363
+
364
+ If encoding is ``"passthrough"``, then the message has the same encoding as the image's OpenCV type.
365
+ Otherwise desired_encoding must be one of the standard image encodings
366
+
367
+ This function returns a sensor_msgs::Image message on success, or raises
368
+ :exc:`cv_bridge.CvBridgeError`on failure.
369
+ """
370
+ import numpy as np
371
+
372
+ if not isinstance(cvim, (np.ndarray, np.generic)):
373
+ raise TypeError("Your input type is not a numpy array")
374
+ img_msg = sensor_msgs.msg.Image()
375
+ img_msg.height = cvim.shape[0]
376
+ img_msg.width = cvim.shape[1]
377
+ if len(cvim.shape) < 3:
378
+ cv_type = self.dtype_with_channels_to_cvtype2(cvim.dtype, 1)
379
+ else:
380
+ cv_type = self.dtype_with_channels_to_cvtype2(cvim.dtype, cvim.shape[2])
381
+ if encoding == "passthrough":
382
+ img_msg.encoding = cv_type
383
+ else:
384
+ img_msg.encoding = encoding
385
+ # # Verify that the supplied encoding is compatible with the type of the OpenCV image
386
+ # if self.cvtype_to_name[self.encoding_to_cvtype2(encoding)] != cv_type:
387
+ # raise CvBridgeError(
388
+ # "encoding specified as %s, but image has incompatible type %s"
389
+ # % (encoding, cv_type)
390
+ # )
391
+ if cvim.dtype.byteorder == ">":
392
+ img_msg.is_bigendian = True
393
+ img_msg.data = cvim.tostring()
394
+ img_msg.step = len(img_msg.data) // img_msg.height
395
+
396
+ return img_msg
GR00T-WholeBodyControl/decoupled_wbc/control/utils/episode_state.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ class EpisodeState:
2
+ """Episode state controller for data collection.
3
+
4
+ Manages the state transitions for episode recording:
5
+ - IDLE: Not recording
6
+ - RECORDING: Currently recording data
7
+ - NEED_TO_SAVE: Recording stopped, waiting to save
8
+ """
9
+
10
+ def __init__(self):
11
+ self.RECORDING = "recording"
12
+ self.IDLE = "idle"
13
+ self.NEED_TO_SAVE = "need_to_save"
14
+
15
+ self.state = self.IDLE
16
+
17
+ def change_state(self):
18
+ """Cycle through states: IDLE -> RECORDING -> NEED_TO_SAVE -> IDLE."""
19
+ if self.state == self.IDLE:
20
+ self.state = self.RECORDING
21
+ elif self.state == self.RECORDING:
22
+ self.state = self.NEED_TO_SAVE
23
+ elif self.state == self.NEED_TO_SAVE:
24
+ self.state = self.IDLE
25
+
26
+ def reset_state(self):
27
+ """Reset to IDLE state."""
28
+ self.state = self.IDLE
29
+
30
+ def get_state(self):
31
+ """Get current state."""
32
+ return self.state
GR00T-WholeBodyControl/decoupled_wbc/control/utils/gear_wbc_utils.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import numpy as np
4
+ import yaml
5
+
6
+
7
+ def load_config(config_path):
8
+ """Load and process the YAML configuration file"""
9
+ with open(config_path, "r") as f:
10
+ config = yaml.safe_load(f)
11
+
12
+ # Set the path to the LEGGED_GYM_ROOT_DIR using relative path
13
+ current_file_dir = os.path.dirname(os.path.abspath(config_path))
14
+ LEGGED_GYM_ROOT_DIR = os.path.join(current_file_dir, "..", "GearWbcRL", "legged_gym")
15
+ LEGGED_GYM_ROOT_DIR = os.path.abspath(LEGGED_GYM_ROOT_DIR)
16
+
17
+ # Process paths with LEGGED_GYM_ROOT_DIR
18
+ for path_key in ["policy_path", "xml_path", "onnx_policy_path"]:
19
+ if path_key in config:
20
+ config[path_key] = config[path_key].format(LEGGED_GYM_ROOT_DIR=LEGGED_GYM_ROOT_DIR)
21
+
22
+ # Convert lists to numpy arrays where needed
23
+ array_keys = ["kps", "kds", "default_angles", "cmd_scale", "cmd_init"]
24
+ for key in array_keys:
25
+ if key in config:
26
+ config[key] = np.array(config[key], dtype=np.float32)
27
+
28
+ return config, LEGGED_GYM_ROOT_DIR
29
+
30
+
31
+ def pd_control(target_q, q, kp, target_dq, dq, kd):
32
+ """Calculates torques from position commands"""
33
+ return (target_q - q) * kp + (target_dq - dq) * kd
34
+
35
+
36
+ def quat_rotate_inverse(q, v):
37
+ """Rotate vector v by the inverse of quaternion q"""
38
+ w = q[..., 0]
39
+ x = q[..., 1]
40
+ y = q[..., 2]
41
+ z = q[..., 3]
42
+
43
+ q_conj = np.array([w, -x, -y, -z])
44
+
45
+ return np.array(
46
+ [
47
+ v[0] * (q_conj[0] ** 2 + q_conj[1] ** 2 - q_conj[2] ** 2 - q_conj[3] ** 2)
48
+ + v[1] * 2 * (q_conj[1] * q_conj[2] - q_conj[0] * q_conj[3])
49
+ + v[2] * 2 * (q_conj[1] * q_conj[3] + q_conj[0] * q_conj[2]),
50
+ v[0] * 2 * (q_conj[1] * q_conj[2] + q_conj[0] * q_conj[3])
51
+ + v[1] * (q_conj[0] ** 2 - q_conj[1] ** 2 + q_conj[2] ** 2 - q_conj[3] ** 2)
52
+ + v[2] * 2 * (q_conj[2] * q_conj[3] - q_conj[0] * q_conj[1]),
53
+ v[0] * 2 * (q_conj[1] * q_conj[3] - q_conj[0] * q_conj[2])
54
+ + v[1] * 2 * (q_conj[2] * q_conj[3] + q_conj[0] * q_conj[1])
55
+ + v[2] * (q_conj[0] ** 2 - q_conj[1] ** 2 - q_conj[2] ** 2 + q_conj[3] ** 2),
56
+ ]
57
+ )
58
+
59
+
60
+ def get_gravity_orientation(quat):
61
+ """Get gravity vector in body frame"""
62
+ gravity_vec = np.array([0.0, 0.0, -1.0])
63
+ return quat_rotate_inverse(quat, gravity_vec)
64
+
65
+
66
+ def compute_observation(d, config, action, cmd, height_cmd, n_joints):
67
+ """Compute the observation vector from current state"""
68
+ # Get state from MuJoCo
69
+ qj = d.qpos[7 : 7 + n_joints].copy()
70
+ dqj = d.qvel[6 : 6 + n_joints].copy()
71
+ quat = d.qpos[3:7].copy()
72
+ omega = d.qvel[3:6].copy()
73
+
74
+ # Handle default angles padding
75
+ if len(config["default_angles"]) < n_joints:
76
+ padded_defaults = np.zeros(n_joints, dtype=np.float32)
77
+ padded_defaults[: len(config["default_angles"])] = config["default_angles"]
78
+ else:
79
+ padded_defaults = config["default_angles"][:n_joints]
80
+
81
+ # Scale the values
82
+ qj_scaled = (qj - padded_defaults) * config["dof_pos_scale"]
83
+ dqj_scaled = dqj * config["dof_vel_scale"]
84
+ gravity_orientation = get_gravity_orientation(quat)
85
+ omega_scaled = omega * config["ang_vel_scale"]
86
+
87
+ # Calculate single observation dimension
88
+ single_obs_dim = 3 + 1 + 3 + 3 + n_joints + n_joints + 12
89
+
90
+ # Create single observation
91
+ single_obs = np.zeros(single_obs_dim, dtype=np.float32)
92
+ single_obs[0:3] = cmd[:3] * config["cmd_scale"]
93
+ single_obs[3:4] = np.array([height_cmd])
94
+ single_obs[4:7] = omega_scaled
95
+ single_obs[7:10] = gravity_orientation
96
+ single_obs[10 : 10 + n_joints] = qj_scaled
97
+ single_obs[10 + n_joints : 10 + 2 * n_joints] = dqj_scaled
98
+ single_obs[10 + 2 * n_joints : 10 + 2 * n_joints + 12] = action
99
+
100
+ return single_obs, single_obs_dim
GR00T-WholeBodyControl/decoupled_wbc/control/utils/keyboard_dispatcher.py ADDED
@@ -0,0 +1,255 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import subprocess
3
+ import sys
4
+ import threading
5
+
6
+ import rclpy
7
+ from sshkeyboard import listen_keyboard, stop_listening
8
+ from std_msgs.msg import String as RosStringMsg
9
+
10
+ from decoupled_wbc.control.main.constants import KEYBOARD_INPUT_TOPIC
11
+
12
+ # Global variable to store original terminal attributes
13
+ _original_terminal_attrs = None
14
+
15
+
16
+ def save_terminal_state():
17
+ """Save the current terminal state."""
18
+ global _original_terminal_attrs
19
+ try:
20
+ import termios
21
+
22
+ fd = sys.stdin.fileno()
23
+ _original_terminal_attrs = termios.tcgetattr(fd)
24
+ except (ImportError, OSError, termios.error):
25
+ _original_terminal_attrs = None
26
+
27
+
28
+ def restore_terminal():
29
+ """Restore terminal to original state."""
30
+ global _original_terminal_attrs
31
+ try:
32
+ import termios
33
+
34
+ if _original_terminal_attrs is not None:
35
+ fd = sys.stdin.fileno()
36
+ termios.tcsetattr(fd, termios.TCSANOW, _original_terminal_attrs)
37
+ return
38
+ except (ImportError, OSError, termios.error):
39
+ pass
40
+
41
+ # Fallback for non-Unix systems or if termios fails
42
+ try:
43
+ if os.name == "posix":
44
+ os.system("stty sane")
45
+ except OSError:
46
+ pass
47
+
48
+
49
+ class ROSKeyboardDispatcher:
50
+ """ROS-based keyboard dispatcher that receives keyboard events via ROS topics."""
51
+
52
+ def __init__(self):
53
+ self.listeners = []
54
+ self._active = False
55
+ assert rclpy.ok(), "Expected ROS2 to be initialized in this process..."
56
+ executor = rclpy.get_global_executor()
57
+ self.node = executor.get_nodes()[0]
58
+ print("creating keyboard input subscriber...")
59
+ self.subscription = self.node.create_subscription(
60
+ RosStringMsg, KEYBOARD_INPUT_TOPIC, self._callback, 10
61
+ )
62
+
63
+ def register(self, listener):
64
+ if not hasattr(listener, "handle_keyboard_button"):
65
+ raise NotImplementedError("handle_keyboard_button is not implemented")
66
+ self.listeners.append(listener)
67
+
68
+ def start(self):
69
+ """Start the ROS keyboard dispatcher."""
70
+ self._active = True
71
+ print("ROS keyboard dispatcher started")
72
+
73
+ def stop(self):
74
+ """Stop the ROS keyboard dispatcher and cleanup."""
75
+ if self._active:
76
+ self._active = False
77
+ # Clean up subscription
78
+ if hasattr(self, "subscription"):
79
+ self.node.destroy_subscription(self.subscription)
80
+ print("ROS keyboard dispatcher stopped")
81
+
82
+ def _callback(self, msg: RosStringMsg):
83
+ if self._active:
84
+ for listener in self.listeners:
85
+ listener.handle_keyboard_button(msg.data)
86
+
87
+ def __del__(self):
88
+ """Cleanup when object is destroyed."""
89
+ self.stop()
90
+
91
+
92
+ class KeyboardDispatcher:
93
+ def __init__(self):
94
+ self.listeners = []
95
+ self._listening_thread = None
96
+ self._stop_event = threading.Event()
97
+ self._key = None
98
+
99
+ def register(self, listener):
100
+ # raise if handle_keyboard_button is not implemented
101
+ # TODO(YL): let listener be a Callable instead of a class
102
+ if not hasattr(listener, "handle_keyboard_button"):
103
+ raise NotImplementedError("handle_keyboard_button is not implemented")
104
+ self.listeners.append(listener)
105
+
106
+ def handle_key(self, key):
107
+ # Check if we should stop
108
+ if self._stop_event.is_set():
109
+ stop_listening()
110
+ return
111
+
112
+ for listener in self.listeners:
113
+ listener.handle_keyboard_button(key)
114
+
115
+ def start_listening(self):
116
+ try:
117
+ save_terminal_state() # Save original terminal state before listening
118
+ listen_keyboard(
119
+ on_press=self.handle_key,
120
+ delay_second_char=0.1,
121
+ delay_other_chars=0.05,
122
+ sleep=0.01,
123
+ )
124
+ except Exception as e:
125
+ print(f"Keyboard listener stopped: {e}")
126
+ finally:
127
+ # Ensure terminal is restored even if an exception occurs
128
+ self._restore_terminal()
129
+
130
+ def start(self):
131
+ self._listening_thread = threading.Thread(target=self.start_listening, daemon=True)
132
+ self._listening_thread.start()
133
+
134
+ def stop(self):
135
+ """Stop the keyboard listener and restore terminal settings."""
136
+ if self._listening_thread and self._listening_thread.is_alive():
137
+ self._stop_event.set()
138
+ # Force stop_listening to be called
139
+ try:
140
+ stop_listening()
141
+ except Exception:
142
+ pass
143
+ # Wait a bit for the thread to finish
144
+ self._listening_thread.join(timeout=0.5)
145
+ # Restore terminal settings
146
+ self._restore_terminal()
147
+
148
+ def _restore_terminal(self):
149
+ """Restore terminal to a sane state."""
150
+ restore_terminal()
151
+
152
+ def __del__(self):
153
+ """Cleanup when object is destroyed."""
154
+ self.stop()
155
+
156
+
157
+ KEYBOARD_LISTENER_TOPIC_NAME = "/Gr00tKeyboardListener"
158
+
159
+
160
+ class KeyboardListener:
161
+ def __init__(self):
162
+ self.key = None
163
+
164
+ def handle_keyboard_button(self, key):
165
+ self.key = key
166
+
167
+ def pop_key(self):
168
+ key = self.key
169
+ self.key = None
170
+ return key
171
+
172
+
173
+ class KeyboardListenerPublisher:
174
+ def __init__(self, topic_name: str = KEYBOARD_LISTENER_TOPIC_NAME):
175
+ """
176
+ Initialize keyboard listener for remote teleop with simplified interface.
177
+
178
+ Args:
179
+ remote_system: RemoteSystem instance
180
+ control_channel_name: Name of the control channel
181
+ """
182
+ assert rclpy.ok(), "Expected ROS2 to be initialized in this process..."
183
+ executor = rclpy.get_global_executor()
184
+ self.node = executor.get_nodes()[0]
185
+ self.publisher = self.node.create_publisher(RosStringMsg, topic_name, 1)
186
+
187
+ def handle_keyboard_button(self, key):
188
+ self.publisher.publish(RosStringMsg(data=key))
189
+
190
+
191
+ class KeyboardListenerSubscriber:
192
+ def __init__(
193
+ self,
194
+ topic_name: str = KEYBOARD_LISTENER_TOPIC_NAME,
195
+ node_name: str = "keyboard_listener_subscriber",
196
+ ):
197
+ assert rclpy.ok(), "Expected ROS2 to be initialized in this process..."
198
+ executor = rclpy.get_global_executor()
199
+ nodes = executor.get_nodes()
200
+ if nodes:
201
+ self.node = nodes[0]
202
+ self._create_node = False
203
+ else:
204
+ self.node = rclpy.create_node("KeyboardListenerSubscriber")
205
+ executor.add_node(self.node)
206
+ self._create_node = True
207
+ self.subscriber = self.node.create_subscription(RosStringMsg, topic_name, self._callback, 1)
208
+ self._data = None
209
+
210
+ def _callback(self, msg: RosStringMsg):
211
+ self._data = msg.data
212
+
213
+ def read_msg(self):
214
+ data = self._data
215
+ self._data = None
216
+ return data
217
+
218
+
219
+ class KeyboardEStop:
220
+ def __init__(self):
221
+ """Initialize KeyboardEStop with automatic tmux cleanup detection."""
222
+ # Automatically create tmux cleanup if in deployment mode
223
+ self.cleanup_callback = self._create_tmux_cleanup_callback()
224
+
225
+ def _create_tmux_cleanup_callback(self):
226
+ """Create a cleanup callback that kills the tmux session if running in deployment mode."""
227
+ tmux_session = os.environ.get("DECOUPLED_WBC_TMUX_SESSION")
228
+
229
+ def cleanup_callback():
230
+ if tmux_session:
231
+ print(f"Emergency stop: Killing tmux session '{tmux_session}'...")
232
+ try:
233
+ subprocess.run(["tmux", "kill-session", "-t", tmux_session], timeout=5)
234
+ print("Tmux session terminated successfully.")
235
+ except subprocess.TimeoutExpired:
236
+ print("Warning: Tmux session termination timed out, forcing kill...")
237
+ try:
238
+ subprocess.run(["tmux", "kill-session", "-t", tmux_session, "-9"])
239
+ except Exception:
240
+ pass
241
+ except Exception as e:
242
+ print(f"Warning: Error during tmux cleanup: {e}")
243
+ # If tmux cleanup fails, fallback to immediate exit
244
+ restore_terminal()
245
+ os._exit(1)
246
+ else:
247
+ print("Emergency stop: No tmux session, exiting normally...")
248
+ sys.exit(1)
249
+
250
+ return cleanup_callback
251
+
252
+ def handle_keyboard_button(self, key):
253
+ if key == "`":
254
+ print("Emergency stop triggered - running cleanup...")
255
+ self.cleanup_callback()
GR00T-WholeBodyControl/decoupled_wbc/control/utils/network_utils.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Simple network interface utilities
4
+ """
5
+
6
+ import platform
7
+ import re
8
+ import subprocess
9
+
10
+
11
+ def get_network_interfaces():
12
+ """Get network interfaces with their IP addresses"""
13
+ try:
14
+ result = subprocess.run(
15
+ ["/sbin/ip", "addr", "show"], capture_output=True, text=True, check=True
16
+ )
17
+ return _parse_ip_output(result.stdout)
18
+ except (subprocess.CalledProcessError, FileNotFoundError):
19
+ try:
20
+ result = subprocess.run(["ifconfig"], capture_output=True, text=True, check=True)
21
+ return _parse_ifconfig_output(result.stdout)
22
+ except (subprocess.CalledProcessError, FileNotFoundError):
23
+ return {}
24
+
25
+
26
+ def _parse_ip_output(output):
27
+ """Parse 'ip addr' command output"""
28
+ interfaces = {}
29
+ current_interface = None
30
+
31
+ for line in output.split("\n"):
32
+ interface_match = re.match(r"^\d+:\s+(\w+):", line)
33
+ if interface_match:
34
+ current_interface = interface_match.group(1)
35
+ interfaces[current_interface] = []
36
+
37
+ ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", line)
38
+ if ip_match and current_interface:
39
+ interfaces[current_interface].append(ip_match.group(1))
40
+
41
+ return interfaces
42
+
43
+
44
+ def _parse_ifconfig_output(output):
45
+ """Parse 'ifconfig' command output"""
46
+ interfaces = {}
47
+ current_interface = None
48
+
49
+ for line in output.split("\n"):
50
+ interface_match = re.match(r"^(\w+):", line)
51
+ if interface_match:
52
+ current_interface = interface_match.group(1)
53
+ interfaces[current_interface] = []
54
+
55
+ ip_match = re.search(r"inet\s+(\d+\.\d+\.\d+\.\d+)", line)
56
+ if ip_match and current_interface:
57
+ interfaces[current_interface].append(ip_match.group(1))
58
+
59
+ return interfaces
60
+
61
+
62
+ def find_interface_by_ip(target_ip):
63
+ """Find interface name for given IP address"""
64
+ interfaces = get_network_interfaces()
65
+ for interface, ip_list in interfaces.items():
66
+ if target_ip in ip_list:
67
+ return interface
68
+ return None
69
+
70
+
71
+ def resolve_interface(interface: str) -> tuple[str, str]:
72
+ """
73
+ Resolve interface parameter to actual network interface name and environment type
74
+
75
+ Args:
76
+ interface: "sim", "real", or direct interface name or IP address
77
+
78
+ Returns:
79
+ tuple: (interface_name, env_type) where env_type is "sim" or "real"
80
+ """
81
+ # Check if interface is an IP address
82
+ if re.match(r"^\d+\.\d+\.\d+\.\d+$", interface):
83
+ if interface == "127.0.0.1":
84
+ return interface, "sim"
85
+ else:
86
+ return interface, "real"
87
+
88
+ if interface == "sim":
89
+ lo_interface = find_interface_by_ip("127.0.0.1")
90
+ if lo_interface:
91
+ # macOS uses lo0 instead of lo
92
+ if platform.system() == "Darwin" and lo_interface == "lo":
93
+ return "lo0", "sim"
94
+ return lo_interface, "sim"
95
+ return ("lo0" if platform.system() == "Darwin" else "lo"), "sim"
96
+
97
+ elif interface == "real":
98
+ interfaces = get_network_interfaces()
99
+ for iface, ip_list in interfaces.items():
100
+ for ip in ip_list:
101
+ if ip.startswith("192.168.123."):
102
+ return iface, "real"
103
+ return interface, "real" # fallback
104
+
105
+ else:
106
+ # Direct interface name - check if it has 127.0.0.1 to determine env_type
107
+ interfaces = get_network_interfaces()
108
+ if interface in interfaces:
109
+ for ip in interfaces[interface]:
110
+ if ip == "127.0.0.1":
111
+ return interface, "sim"
112
+
113
+ # macOS lo interface handling
114
+ if platform.system() == "Darwin" and interface == "lo":
115
+ return "lo0", "sim"
116
+
117
+ # Default to real for unknown interfaces
118
+ return interface, "real"
119
+
120
+
121
+ if __name__ == "__main__":
122
+ interfaces = get_network_interfaces()
123
+
124
+ if not interfaces:
125
+ print("No network interfaces found")
126
+ exit(1)
127
+
128
+ # Show all interfaces
129
+ print("Network interfaces:")
130
+ for interface, ip_list in interfaces.items():
131
+ print(f" {interface}: {', '.join(ip_list)}")
132
+
133
+ # Test resolve_interface function
134
+ print("\nTesting resolve_interface:")
135
+ for test_interface in ["sim", "real", "lo", "127.0.0.1"]:
136
+ interface_name, env_type = resolve_interface(test_interface)
137
+ print(f" {test_interface} -> {interface_name} ({env_type})")
GR00T-WholeBodyControl/decoupled_wbc/control/utils/ros_utils.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import base64
2
+ import signal
3
+ import threading
4
+ from typing import Optional
5
+
6
+ import msgpack
7
+ import msgpack_numpy as mnp
8
+ import rclpy
9
+ from rclpy.executors import SingleThreadedExecutor
10
+ from rclpy.node import Node
11
+ from sensor_msgs.msg import Image
12
+ from std_msgs.msg import ByteMultiArray
13
+ from std_srvs.srv import Trigger
14
+
15
+ _signal_registered = False
16
+
17
+
18
+ def register_keyboard_interrupt_handler():
19
+ """
20
+ Register a signal handler for SIGINT (Ctrl+C) and SIGTERM that raises KeyboardInterrupt.
21
+ This ensures consistent exception handling across different termination signals.
22
+ """
23
+ global _signal_registered
24
+ if not _signal_registered:
25
+
26
+ def signal_handler(signum, frame):
27
+ raise KeyboardInterrupt
28
+
29
+ signal.signal(signal.SIGINT, signal_handler)
30
+ signal.signal(signal.SIGTERM, signal_handler)
31
+ _signal_registered = True
32
+
33
+
34
+ class ROSManager:
35
+ """
36
+ Manages the ROS2 node and executor.
37
+
38
+ Usage example:
39
+ ```python
40
+ def main():
41
+ ros_manager = ROSManager()
42
+ node = ros_manager.node
43
+
44
+ try:
45
+ while ros_manager.ok():
46
+ time.sleep(0.1)
47
+ except ros_manager.exceptions() as e:
48
+ print(f"ROSManager interrupted by user: {e}")
49
+ finally:
50
+ ros_manager.shutdown()
51
+ ```
52
+ """
53
+
54
+ def __init__(self, node_name: str = "ros_manager"):
55
+ if not rclpy.ok():
56
+ rclpy.init()
57
+ self.node = rclpy.create_node(node_name)
58
+ self.thread = threading.Thread(target=rclpy.spin, args=(self.node,), daemon=True)
59
+ self.thread.start()
60
+ else:
61
+ executor = rclpy.get_global_executor()
62
+ if len(executor.get_nodes()) > 0:
63
+ self.node = executor.get_nodes()[0]
64
+ else:
65
+ self.node = rclpy.create_node(node_name)
66
+
67
+ register_keyboard_interrupt_handler()
68
+
69
+ @staticmethod
70
+ def ok():
71
+ return rclpy.ok()
72
+
73
+ @staticmethod
74
+ def shutdown():
75
+ if rclpy.ok():
76
+ rclpy.shutdown()
77
+
78
+ @staticmethod
79
+ def exceptions():
80
+ return (rclpy.exceptions.ROSInterruptException, KeyboardInterrupt)
81
+
82
+
83
+ class ROSMsgPublisher:
84
+ """
85
+ Publishes any serializable dict to a topic.
86
+ """
87
+
88
+ def __init__(self, topic_name: str):
89
+ ros_manager = ROSManager()
90
+ self.node = ros_manager.node
91
+ self.publisher = self.node.create_publisher(ByteMultiArray, topic_name, 1)
92
+
93
+ def publish(self, msg: dict):
94
+ payload = msgpack.packb(msg, default=mnp.encode)
95
+ payload = tuple(bytes([a]) for a in payload)
96
+ msg = ByteMultiArray()
97
+ msg.data = payload
98
+ self.publisher.publish(msg)
99
+
100
+
101
+ class ROSMsgSubscriber:
102
+ """
103
+ Subscribes to any topics published by a ROSMsgPublisher.
104
+ """
105
+
106
+ def __init__(self, topic_name: str):
107
+ ros_manager = ROSManager()
108
+ self.node = ros_manager.node
109
+ self._msg = None
110
+ self.subscription = self.node.create_subscription(
111
+ ByteMultiArray, topic_name, self._callback, 1
112
+ )
113
+
114
+ def _callback(self, msg: ByteMultiArray):
115
+ self._msg = msg
116
+
117
+ def get_msg(self) -> Optional[dict]:
118
+ msg = self._msg
119
+ if msg is None:
120
+ return None
121
+ self._msg = None
122
+ return msgpack.unpackb(bytes([ab for a in msg.data for ab in a]), object_hook=mnp.decode)
123
+
124
+
125
+ class ROSImgMsgSubscriber:
126
+ """
127
+ Subscribes to an `Image` topic and returns the image as a numpy array and timestamp.
128
+ """
129
+
130
+ def __init__(self, topic_name: str):
131
+ ros_manager = ROSManager()
132
+ self.node = ros_manager.node
133
+ self._msg = None
134
+ self.subscription = self.node.create_subscription(Image, topic_name, self._callback, 1)
135
+
136
+ from decoupled_wbc.control.utils.cv_bridge import CvBridge
137
+
138
+ self.bridge = CvBridge()
139
+
140
+ def _callback(self, msg: Image):
141
+ self._msg = msg
142
+
143
+ def get_image(self) -> Optional[dict]:
144
+ """
145
+ Returns the image as a numpy array and the timestamp.
146
+ """
147
+
148
+ msg = self._msg
149
+ if msg is None:
150
+ return None
151
+ return {
152
+ "image": self.bridge.imgmsg_to_cv2(msg),
153
+ "timestamp": msg.header.stamp.sec + msg.header.stamp.nanosec * 1e-9,
154
+ }
155
+
156
+
157
+ class ROSServiceServer:
158
+ """
159
+ Generic ROS2 Service server that stores and serves a config dict.
160
+ """
161
+
162
+ def __init__(self, service_name: str, config: dict):
163
+ ros_manager = ROSManager()
164
+ self.node = ros_manager.node
165
+ packed = msgpack.packb(config, default=mnp.encode)
166
+ self.message = base64.b64encode(packed).decode("ascii")
167
+ self.server = self.node.create_service(Trigger, service_name, self._callback)
168
+
169
+ def _callback(self, request, response):
170
+ try:
171
+ response.success = True
172
+ response.message = self.message
173
+ print("Sending encoded message of length:", len(response.message))
174
+ except Exception as e:
175
+ response.success = False
176
+ response.message = str(e)
177
+ return response
178
+
179
+
180
+ class ROSServiceClient(Node):
181
+
182
+ def __init__(self, service_name: str, node_name: str = "service_client"):
183
+ super().__init__(node_name)
184
+ self.cli = self.create_client(Trigger, service_name)
185
+ while not self.cli.wait_for_service(timeout_sec=1.0):
186
+ self.get_logger().info("service not available, waiting again...")
187
+ self.req = Trigger.Request()
188
+
189
+ def get_config(self):
190
+ future = self.cli.call_async(self.req)
191
+ executor = SingleThreadedExecutor()
192
+ executor.add_node(self)
193
+ executor.spin_until_future_complete(future, timeout_sec=1.0)
194
+ executor.remove_node(self)
195
+ executor.shutdown()
196
+ result = future.result()
197
+ if result.success:
198
+ decoded = base64.b64decode(result.message.encode("ascii"))
199
+ return msgpack.unpackb(decoded, object_hook=mnp.decode)
200
+ else:
201
+ raise RuntimeError(f"Service call failed: {result.message}")
GR00T-WholeBodyControl/decoupled_wbc/control/utils/run_real_checklist.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+
3
+ import sys
4
+
5
+
6
+ def check_real_deployment(extra_args):
7
+ """Check if this is a real robot deployment."""
8
+ is_real_deployment = False
9
+
10
+ # Check if interface argument is provided and not 'lo' or 'lo0'
11
+ for i, arg in enumerate(extra_args):
12
+ if arg == "--interface":
13
+ # Get the next argument (interface value)
14
+ if i + 1 < len(extra_args):
15
+ interface_value = extra_args[i + 1]
16
+ if interface_value not in ["lo", "lo0"]:
17
+ is_real_deployment = True
18
+ print(f"Real deployment detected: interface = {interface_value}")
19
+ break
20
+ else:
21
+ print(f"Simulation deployment detected: interface = {interface_value}")
22
+
23
+ # If no interface specified, assume simulation (default is 'lo' in deploy_g1.py)
24
+ if not is_real_deployment:
25
+ print("No interface specified - assuming simulation (default interface = lo)")
26
+
27
+ return is_real_deployment
28
+
29
+
30
+ def show_deployment_checklist():
31
+ """Show deployment checklist and get confirmation."""
32
+ checklist_content = """═══════════════════════════════════════════════════════════════════════════════
33
+ G1 ROBOT DEPLOYMENT CHECKLIST
34
+ ═══════════════════════════════════════════════════════════════════════════════
35
+
36
+ ⚠️ SAFETY VERIFICATION - Complete ALL checks before deployment
37
+
38
+ PRE-DEPLOYMENT CHECKLIST:
39
+
40
+ □ Sim2Sim Verification
41
+ Test in simulation first with interface set to 'sim' before real deployment
42
+
43
+ □ Camera System Check
44
+ Test real camera with simulation environment before full deployment
45
+
46
+ □ State Reading Validation
47
+ • Disable action queue
48
+ • Verify sensor readings (IMU, joints, fingers)
49
+ • Use rerun for visualization
50
+ • Contact: Dennis Da (xda@nvidia.com) for assistance
51
+
52
+ □ Low Gain Test
53
+ • Start with low kp values (2-5x lower than normal)
54
+ • Keep kd values unchanged
55
+
56
+ □ Clear Workspace
57
+ • Remove obstacles and avoid tables
58
+ • Ensure adequate clearance in all directions
59
+
60
+ □ Emergency Stop Ready
61
+ Ensure access to at least one:
62
+ • Keyboard e-stop
63
+ • Joycon controller
64
+ • External power cutoff
65
+
66
+ ═══════════════════════════════════════════════════════════════════════════════
67
+ 🚨 EMERGENCY: Press ` at any time to stop all processes
68
+ 📹 RECORDING: Connect a webcam to your computer to record the experiment
69
+ ═══════════════════════════════════════════════════════════════════════════════
70
+
71
+ Usages:
72
+
73
+ - hit ` to stop all processes
74
+ - hit Ctrl+C to stop single process
75
+ - hit Ctrl+\ to quit the tmux
76
+ """
77
+
78
+ print("")
79
+ print("🚨 REAL ROBOT DEPLOYMENT DETECTED 🚨")
80
+ print("")
81
+ print(checklist_content)
82
+ print("")
83
+
84
+ # Get user confirmation
85
+ while True:
86
+ user_input = input("Continue with deployment? [Y/n]: ").strip()
87
+
88
+ # Default to Y if empty input
89
+ if not user_input:
90
+ user_input = "Y"
91
+
92
+ user_input_upper = user_input.upper()
93
+
94
+ if user_input_upper in ["Y", "YES"]:
95
+ print("")
96
+ print("✅ Deployment confirmed. Proceeding with robot deployment...")
97
+ print("")
98
+ return True
99
+ elif user_input_upper in ["N", "NO"]:
100
+ print("")
101
+ print("❌ Deployment aborted by user.")
102
+ print("")
103
+ return False
104
+ else:
105
+ print(
106
+ "❌ Invalid input. Please enter 'Y' for yes, 'N' for no, or press Enter for default (Y)."
107
+ )
108
+
109
+
110
+ def main():
111
+ """Main function."""
112
+ # Always show the checklist
113
+ if not show_deployment_checklist():
114
+ print("Deployment cancelled.")
115
+ sys.exit(1)
116
+
117
+ return 0
118
+
119
+
120
+ if __name__ == "__main__":
121
+ main()
GR00T-WholeBodyControl/decoupled_wbc/control/utils/service.py ADDED
@@ -0,0 +1,182 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+
16
+ from dataclasses import dataclass
17
+ from io import BytesIO
18
+ from typing import Any, Callable, Dict
19
+
20
+ import torch
21
+ import zmq
22
+
23
+
24
+ class TorchSerializer:
25
+ @staticmethod
26
+ def to_bytes(data: dict) -> bytes:
27
+ buffer = BytesIO()
28
+ torch.save(data, buffer)
29
+ return buffer.getvalue()
30
+
31
+ @staticmethod
32
+ def from_bytes(data: bytes) -> dict:
33
+ buffer = BytesIO(data)
34
+ obj = torch.load(buffer, weights_only=False)
35
+ return obj
36
+
37
+
38
+ @dataclass
39
+ class EndpointHandler:
40
+ handler: Callable
41
+ requires_input: bool = True
42
+
43
+
44
+ class BaseInferenceServer:
45
+ """
46
+ An inference server that spin up a ZeroMQ socket and listen for incoming requests.
47
+ Can add custom endpoints by calling `register_endpoint`.
48
+ """
49
+
50
+ def __init__(self, host: str = "*", port: int = 5555):
51
+ self.running = True
52
+ self.context = zmq.Context()
53
+ self.socket = self.context.socket(zmq.REP)
54
+ self.socket.bind(f"tcp://{host}:{port}")
55
+ self._endpoints: dict[str, EndpointHandler] = {}
56
+
57
+ # Register the ping endpoint by default
58
+ self.register_endpoint("ping", self._handle_ping, requires_input=False)
59
+ self.register_endpoint("kill", self._kill_server, requires_input=False)
60
+
61
+ def _kill_server(self):
62
+ """
63
+ Kill the server.
64
+ """
65
+ self.running = False
66
+
67
+ def _handle_ping(self) -> dict:
68
+ """
69
+ Simple ping handler that returns a success message.
70
+ """
71
+ return {"status": "ok", "message": "Server is running"}
72
+
73
+ def register_endpoint(self, name: str, handler: Callable, requires_input: bool = True):
74
+ """
75
+ Register a new endpoint to the server.
76
+
77
+ Args:
78
+ name: The name of the endpoint.
79
+ handler: The handler function that will be called when the endpoint is hit.
80
+ requires_input: Whether the handler requires input data.
81
+ """
82
+ self._endpoints[name] = EndpointHandler(handler, requires_input)
83
+
84
+ def run(self):
85
+ addr = self.socket.getsockopt_string(zmq.LAST_ENDPOINT)
86
+ print(f"Server is ready and listening on {addr}")
87
+ while self.running:
88
+ try:
89
+ message = self.socket.recv()
90
+ request = TorchSerializer.from_bytes(message)
91
+ endpoint = request.get("endpoint", "get_action")
92
+
93
+ if endpoint not in self._endpoints:
94
+ raise ValueError(f"Unknown endpoint: {endpoint}")
95
+
96
+ handler = self._endpoints[endpoint]
97
+ result = (
98
+ handler.handler(request.get("data", {}))
99
+ if handler.requires_input
100
+ else handler.handler()
101
+ )
102
+ self.socket.send(TorchSerializer.to_bytes(result))
103
+ except Exception as e:
104
+ print(f"Error in server: {e}")
105
+ import traceback
106
+
107
+ print(traceback.format_exc())
108
+ self.socket.send(b"ERROR")
109
+
110
+
111
+ class BaseInferenceClient:
112
+ def __init__(self, host: str = "localhost", port: int = 5555, timeout_ms: int = 15000):
113
+ self.context = zmq.Context()
114
+ self.host = host
115
+ self.port = port
116
+ self.timeout_ms = timeout_ms
117
+ self._init_socket()
118
+
119
+ def _init_socket(self):
120
+ """Initialize or reinitialize the socket with current settings"""
121
+ self.socket = self.context.socket(zmq.REQ)
122
+ self.socket.connect(f"tcp://{self.host}:{self.port}")
123
+
124
+ def ping(self) -> bool:
125
+ try:
126
+ self.call_endpoint("ping", requires_input=False)
127
+ return True
128
+ except zmq.error.ZMQError:
129
+ self._init_socket() # Recreate socket for next attempt
130
+ return False
131
+
132
+ def kill_server(self):
133
+ """
134
+ Kill the server.
135
+ """
136
+ self.call_endpoint("kill", requires_input=False)
137
+
138
+ def call_endpoint(
139
+ self, endpoint: str, data: dict | None = None, requires_input: bool = True
140
+ ) -> dict:
141
+ """
142
+ Call an endpoint on the server.
143
+
144
+ Args:
145
+ endpoint: The name of the endpoint.
146
+ data: The input data for the endpoint.
147
+ requires_input: Whether the endpoint requires input data.
148
+ """
149
+ request: dict = {"endpoint": endpoint}
150
+ if requires_input:
151
+ request["data"] = data
152
+
153
+ self.socket.send(TorchSerializer.to_bytes(request))
154
+ message = self.socket.recv()
155
+ if message == b"ERROR":
156
+ raise RuntimeError("Server error")
157
+ return TorchSerializer.from_bytes(message)
158
+
159
+ def __del__(self):
160
+ """Cleanup resources on destruction"""
161
+ self.socket.close()
162
+ self.context.term()
163
+
164
+
165
+ class ExternalRobotInferenceClient(BaseInferenceClient):
166
+ """
167
+ Client for communicating with the RealRobotServer
168
+ """
169
+
170
+ def set_observation(self, observation: dict[str, Any]):
171
+ self.call_endpoint("set_observation", data=observation)
172
+
173
+ def get_action(self, time: float | None = None) -> Dict[str, Any]:
174
+ """
175
+ Get the action from the server.
176
+ The exact definition of the observations is defined
177
+ by the policy, which contains the modalities configuration.
178
+ """
179
+ return self.call_endpoint("get_action", data={"time": time})
180
+
181
+ def get_modality_config(self) -> dict[str, Any]:
182
+ return self.call_endpoint("get_modality_config")
GR00T-WholeBodyControl/decoupled_wbc/control/utils/term_color_constants.py ADDED
@@ -0,0 +1,19 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ GREEN_BOLD = "\033[1;32m"
2
+ RED_BOLD = "\033[1;31m"
3
+ YELLOW_BOLD = "\033[1;33m"
4
+ BLUE_BOLD = "\033[1;34m"
5
+ MAGENTA_BOLD = "\033[1;35m"
6
+ CYAN_BOLD = "\033[1;36m"
7
+ WHITE_BOLD = "\033[1;37m"
8
+ GREY_BOLD = "\033[1;90m"
9
+
10
+ GREEN = "\033[32m"
11
+ RED = "\033[31m"
12
+ YELLOW = "\033[33m"
13
+ BLUE = "\033[34m"
14
+ MAGENTA = "\033[35m"
15
+ CYAN = "\033[36m"
16
+ WHITE = "\033[37m"
17
+ GREY = "\033[90m"
18
+
19
+ RESET = "\033[0m"
GR00T-WholeBodyControl/decoupled_wbc/control/utils/text_to_speech.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # text to speech
2
+ import pyttsx3
3
+
4
+
5
+ class TextToSpeech:
6
+ def __init__(self, rate: int = 150, volume: float = 1.0):
7
+ try:
8
+ self.engine = pyttsx3.init(driverName="espeak")
9
+ self.engine.setProperty("rate", rate)
10
+ self.engine.setProperty("volume", volume)
11
+ except Exception as e:
12
+ print(f"[Text To Speech] Initialization failed: {e}")
13
+ self.engine = None
14
+
15
+ def say(self, message: str):
16
+ """Speak the message if engine is available."""
17
+ if self.engine:
18
+ try:
19
+ self.engine.say(message)
20
+ self.engine.runAndWait()
21
+ except Exception as e:
22
+ print(f"[Text To Speech] Failed to say message: {e}")
23
+
24
+ def print_and_say(self, message: str, say: bool = True):
25
+ """Print message and optionally speak it using Text To Speech."""
26
+ print(message)
27
+ if say and self.engine is not None:
28
+ self.say(message)
GR00T-WholeBodyControl/decoupled_wbc/control/visualization/humanoid_visualizer.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+
3
+ import meshcat_shapes
4
+ import numpy as np
5
+ from pinocchio.visualize import MeshcatVisualizer
6
+
7
+ from decoupled_wbc.control.robot_model import RobotModel
8
+ from decoupled_wbc.control.robot_model.instantiation.g1 import instantiate_g1_robot_model
9
+
10
+
11
+ class RobotVisualizer:
12
+ def __init__(self, robot: RobotModel):
13
+ self.robot = robot
14
+ self.viz = MeshcatVisualizer(
15
+ self.robot.pinocchio_wrapper.model,
16
+ self.robot.pinocchio_wrapper.collision_model,
17
+ self.robot.pinocchio_wrapper.visual_model,
18
+ )
19
+ try:
20
+ self.viz.initViewer(open=True)
21
+
22
+ except ImportError as err:
23
+ print("Error while initializing the viewer. It seems you should install Python meshcat")
24
+ print(err)
25
+ exit(0)
26
+
27
+ self.viz.loadViewerModel()
28
+ self.viz.display(self.robot.q_zero)
29
+
30
+ # Visualize frames
31
+ self.viz_frames = [self.robot.supplemental_info.root_frame_name]
32
+ for side in ["left", "right"]:
33
+ self.viz_frames.append(self.robot.supplemental_info.hand_frame_names[side])
34
+ for frame in self.viz_frames:
35
+ meshcat_shapes.frame(self.viz.viewer[frame], opacity=1.0)
36
+
37
+ def visualize(self, robot_state: np.ndarray):
38
+ # visualize robot state
39
+ if robot_state is not None:
40
+ self.robot.cache_forward_kinematics(robot_state, auto_clip=False)
41
+ self.viz.display(robot_state)
42
+ for frame_name in self.viz_frames:
43
+ self.viz.viewer[frame_name].set_transform(self.robot.frame_placement(frame_name).np)
44
+
45
+
46
+ if __name__ == "__main__":
47
+ # robot_model = instantiate_gr1_robot_model()
48
+ robot_model = instantiate_g1_robot_model()
49
+ visualizer = RobotVisualizer(robot_model)
50
+ while True:
51
+ visualizer.visualize(robot_model.q_zero)
52
+ time.sleep(0.01)
GR00T-WholeBodyControl/decoupled_wbc/control/visualization/meshcat_visualizer_env.py ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import contextmanager
2
+ import time
3
+
4
+ import gymnasium as gym
5
+ import numpy as np
6
+ from pinocchio.visualize import MeshcatVisualizer
7
+
8
+ from decoupled_wbc.control.base.env import Env
9
+ from decoupled_wbc.control.robot_model import RobotModel
10
+
11
+
12
+ class MeshcatVisualizerEnv(Env):
13
+ def __init__(self, robot_model: RobotModel):
14
+ self.robot_model = robot_model
15
+ self.viz = MeshcatVisualizer(
16
+ self.robot_model.pinocchio_wrapper.model,
17
+ self.robot_model.pinocchio_wrapper.collision_model,
18
+ self.robot_model.pinocchio_wrapper.visual_model,
19
+ )
20
+ try:
21
+ self.viz.initViewer(open=True)
22
+
23
+ except ImportError as err:
24
+ print("Error while initializing the viewer. It seems you should install Python meshcat")
25
+ print(err)
26
+ exit(0)
27
+
28
+ self.viz.loadViewerModel()
29
+ self.visualize(self.robot_model.pinocchio_wrapper.q0)
30
+ time.sleep(1.0)
31
+
32
+ self._observation_space = gym.spaces.Dict(
33
+ {
34
+ "q": gym.spaces.Box(
35
+ low=-2 * np.pi, high=2 * np.pi, shape=(self.robot_model.num_dofs,)
36
+ )
37
+ }
38
+ )
39
+ self._action_space = gym.spaces.Dict(
40
+ {
41
+ "q": gym.spaces.Box(
42
+ low=-2 * np.pi, high=2 * np.pi, shape=(self.robot_model.num_dofs,)
43
+ )
44
+ }
45
+ )
46
+
47
+ def visualize(self, robot_state: np.ndarray):
48
+ # visualize robot state
49
+ if robot_state is not None:
50
+ self.viz.display(robot_state)
51
+
52
+ def observe(self):
53
+ # Dummy observation
54
+ return {"q": self.robot_model.pinocchio_wrapper.q0}
55
+
56
+ def queue_action(self, action: dict[str, np.ndarray]):
57
+ self.visualize(action["q"])
58
+
59
+ def reset(self, **kwargs):
60
+ self.visualize(self.robot_model.pinocchio_wrapper.q0)
61
+ return {"q": self.robot_model.pinocchio_wrapper.q0}
62
+
63
+ def sensors(self) -> dict[str, any]:
64
+ return {}
65
+
66
+ def observation_space(self) -> gym.Space:
67
+ return self._observation_space
68
+
69
+ def action_space(self) -> gym.Space:
70
+ return self._action_space
71
+
72
+ def close(self):
73
+ return
74
+
75
+ @contextmanager
76
+ def activate(self):
77
+ yield
GR00T-WholeBodyControl/decoupled_wbc/data/constants.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # This will be used for both sim and real data collection
2
+ RS_VIEW_CAMERA_HEIGHT = 480
3
+ RS_VIEW_CAMERA_WIDTH = 640
4
+
5
+ BUCKET_BASE_PATH = "GearRawDataLeRobotV0"
GR00T-WholeBodyControl/decoupled_wbc/data/exporter.py ADDED
@@ -0,0 +1,514 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import copy
2
+ from dataclasses import dataclass
3
+ from functools import partial
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import shutil
8
+ from typing import Optional
9
+
10
+ import datasets
11
+ from datasets import load_dataset
12
+ from datasets.utils import disable_progress_bars
13
+ from huggingface_hub.errors import RepositoryNotFoundError
14
+ from lerobot.common.datasets.lerobot_dataset import (
15
+ LeRobotDataset,
16
+ LeRobotDatasetMetadata,
17
+ compute_episode_stats,
18
+ )
19
+ from lerobot.common.datasets.utils import (
20
+ check_timestamps_sync,
21
+ get_episode_data_index,
22
+ validate_episode_buffer,
23
+ validate_frame,
24
+ )
25
+ import numpy as np
26
+ from PIL import Image as PILImage
27
+ import torch
28
+ from torchvision import transforms
29
+
30
+ from decoupled_wbc.control.main.config_template import ArgsConfig
31
+ from decoupled_wbc.data.video_writer import VideoWriter
32
+
33
+ disable_progress_bars() # Disable HuggingFace progress bars
34
+
35
+
36
+ @dataclass
37
+ class DataCollectionInfo:
38
+ """
39
+ This dataclass stores additional information that is relevant to the data collection process.
40
+ """
41
+
42
+ lower_body_policy: Optional[str] = None
43
+ wbc_model_path: Optional[str] = None
44
+ teleoperator_username: Optional[str] = None
45
+ support_operator_username: Optional[str] = None
46
+ robot_type: Optional[str] = None
47
+ robot_id: Optional[str] = None
48
+
49
+ def to_dict(self) -> dict:
50
+ """Convert the dataclass to a dictionary for JSON serialization."""
51
+ return {
52
+ "lower_body_policy": self.lower_body_policy,
53
+ "wbc_model_path": self.wbc_model_path,
54
+ "teleoperator_username": self.teleoperator_username,
55
+ "support_operator_username": self.support_operator_username,
56
+ "robot_type": self.robot_type,
57
+ "robot_id": self.robot_id,
58
+ }
59
+
60
+ @classmethod
61
+ def from_dict(cls, data: dict) -> "DataCollectionInfo":
62
+ """Create a DataCollectionInfo instance from a dictionary."""
63
+ return cls(**data)
64
+
65
+
66
+ class Gr00tDatasetMetadata(LeRobotDatasetMetadata):
67
+ """
68
+ Additional metadata on top of LeRobotDatasetMetadata:
69
+ - modality_config: Written to `meta/modality.json`
70
+ - discarded_episode_indices: List of episode indices that were discarded. Written to `meta/info.json`
71
+ """
72
+
73
+ MODALITY_CONFIG_REL_PATH = Path("meta/modality.json")
74
+
75
+ def __init__(self, *args, **kwargs):
76
+ super().__init__(*args, **kwargs)
77
+ with open(self.root / self.MODALITY_CONFIG_REL_PATH, "rb") as f:
78
+ self.modality_config = json.load(f)
79
+
80
+ @classmethod
81
+ def create(
82
+ cls,
83
+ modality_config: dict,
84
+ script_config: dict,
85
+ data_collection_info: DataCollectionInfo,
86
+ *args,
87
+ **kwargs,
88
+ ):
89
+ cls.validate_modality_config(modality_config)
90
+
91
+ # Create base metadata object using parent class
92
+ obj = super().create(*args, **kwargs)
93
+
94
+ # we also need to initialize the discarded_episode_indices
95
+ obj.info["script_config"] = script_config
96
+ obj.info["discarded_episode_indices"] = []
97
+ obj.info["data_collection_info"] = data_collection_info.to_dict()
98
+ with open(obj.root / "meta" / "info.json", "w") as f:
99
+ json.dump(obj.info, f, indent=4)
100
+
101
+ obj.__class__ = cls
102
+ with open(obj.root / cls.MODALITY_CONFIG_REL_PATH, "w") as f:
103
+ json.dump(modality_config, f, indent=4)
104
+ obj.modality_config = modality_config
105
+ return obj
106
+
107
+ @staticmethod
108
+ def validate_modality_config(modality_config: dict) -> None:
109
+ # verify if it contains all state, action, video, annotation keys
110
+ valid_keys = ["state", "action", "video", "annotation"]
111
+ if not all(key in modality_config for key in valid_keys):
112
+ raise ValueError(
113
+ f"Modality config must contain all of the following keys: {valid_keys}"
114
+ )
115
+
116
+ # verify that each key has a modality_config dict
117
+ for key in valid_keys:
118
+ if key not in modality_config:
119
+ raise ValueError(f"Modality config must contain a '{key}' key")
120
+
121
+
122
+ class Gr00tDataExporter(LeRobotDataset):
123
+ """
124
+ A class for exporting data collected for a single session to LeRobot Dataset.
125
+
126
+ Intended life cycle:
127
+ 1. Create a Gr00tDataExporter object
128
+ 2. Add frames using add_frame()
129
+ 3. Save the episode using save_episode()
130
+ - This will flush the episode buffer to disk
131
+ - This will also close the video writers
132
+ - Create a new video writer and ep buffer to start new episode
133
+
134
+ If interrupted, here's the indented behavior:
135
+ - Interruption before save_episode() is called: loses the current episode
136
+ - Interruption after save_episode() is called: keeps completed episodes
137
+ """
138
+
139
+ def __init__(self, *args, **kwargs):
140
+ super().__init__(*args, **kwargs)
141
+ self.video_writers = self.create_video_writer()
142
+
143
+ @property
144
+ def repo_id(self):
145
+ return self.meta.repo_id
146
+
147
+ @property
148
+ def root(self):
149
+ return self.meta.root
150
+
151
+ @property
152
+ def local_files_only(self):
153
+ return self.meta.local_files_only
154
+
155
+ @property
156
+ def video_keys(self):
157
+ return self.meta.video_keys
158
+
159
+ @classmethod
160
+ def create(
161
+ cls,
162
+ save_root: str | Path,
163
+ fps: int,
164
+ features: dict,
165
+ modality_config: dict,
166
+ task: str,
167
+ script_config: ArgsConfig = ArgsConfig(),
168
+ data_collection_info: DataCollectionInfo = DataCollectionInfo(),
169
+ robot_type: str | None = None,
170
+ tolerance_s: float = 1e-4,
171
+ vcodec: str = "h264",
172
+ overwrite_existing: bool = False,
173
+ upload_bucket_path: str | None = None,
174
+ ) -> "Gr00tDataExporter":
175
+ """
176
+ Create a Gr00tDataExporter object.
177
+
178
+ Args:
179
+ save_root: The root directory to save the dataset.
180
+ fps: The frame rate of the dataset.
181
+ features: The features of the dataset.
182
+ modality_config: The modality config of the dataset.
183
+ task: The task performed during the data collection session.
184
+ data_collection_info: The data collection info.
185
+ If the dataset already exists, this argument will be ignored.
186
+ If data_collection_info is not provided, it will be set to an empty DataCollectionInfo object.
187
+ robot_type: The type of robot.
188
+ tolerance_s: The tolerance for the dataset.
189
+ image_writer_processes: The number of processes to use for the image writer.
190
+ image_writer_threads: The number of threads to use for the image writer.
191
+ vcodec: The codec to use for the video writer.
192
+ """
193
+
194
+ obj = cls.__new__(cls)
195
+ repo_id = (
196
+ "tmp/tmp_dataset" # NOTE(fengyuanh): Not relevant since we are not pushing to the hub
197
+ )
198
+ if overwrite_existing and (Path(save_root)).exists():
199
+ print(
200
+ f"Found existing dataset at {save_root}",
201
+ "Cleaning up this directory since overwrite_existing is True.",
202
+ )
203
+ shutil.rmtree(save_root)
204
+
205
+ if (Path(save_root)).exists():
206
+ # Try to resume from existing dataset
207
+ try:
208
+ # Load the metadata
209
+ obj.meta = Gr00tDatasetMetadata(
210
+ repo_id=repo_id,
211
+ root=save_root,
212
+ )
213
+
214
+ except RepositoryNotFoundError as e:
215
+ raise ValueError(
216
+ f"Failed to resume from corrupted dataset. Please manually check the dataset at {save_root}"
217
+ ) from e
218
+ else:
219
+ if not isinstance(script_config, dict):
220
+ script_config = script_config.to_dict()
221
+ obj.meta = Gr00tDatasetMetadata.create(
222
+ repo_id=repo_id,
223
+ fps=fps,
224
+ root=save_root,
225
+ # NOTE(fengyuanh): We use "robot_type" instead of this field which requires a Robot object
226
+ robot=None,
227
+ robot_type=robot_type,
228
+ features=features,
229
+ modality_config=modality_config,
230
+ script_config=script_config,
231
+ # NOTE(fengyuanh): Always use videos for exporting
232
+ use_videos=True,
233
+ data_collection_info=data_collection_info,
234
+ )
235
+ obj.tolerance_s = tolerance_s
236
+ obj.video_backend = (
237
+ "pyav" # NOTE(fengyuanh): Only used in training, not relevant for exporting
238
+ )
239
+ obj.vcodec = vcodec
240
+ obj.task = task
241
+ obj.image_writer = None
242
+
243
+ obj.episode_buffer = obj.create_episode_buffer()
244
+
245
+ obj.episodes = None
246
+ obj.hf_dataset = obj.create_hf_dataset()
247
+ obj.image_transforms = None
248
+ obj.delta_timestamps = None
249
+ obj.delta_indices = None
250
+ obj.episode_data_index = None
251
+ obj.upload_bucket_path = upload_bucket_path
252
+ obj.video_writers = obj.create_video_writer()
253
+ return obj
254
+
255
+ def create_video_writer(self) -> dict[str, VideoWriter]:
256
+ video_writers = {}
257
+ for key in self.meta.video_keys:
258
+ video_writers[key] = VideoWriter(
259
+ self.root
260
+ / self.meta.get_video_file_path(self.episode_buffer["episode_index"], key),
261
+ self.meta.shapes[key][1],
262
+ self.meta.shapes[key][0],
263
+ self.fps,
264
+ self.vcodec,
265
+ )
266
+ return video_writers
267
+
268
+ # @note (k2): This function is copied from LeRobotDataset.add_frame.
269
+ # This is done because we want to bypass lerobot's
270
+ # image_writer and use our own VideoWriter class.
271
+ def add_frame(self, frame: dict) -> None:
272
+ """
273
+ This function only adds the frame to the episode_buffer. Videos are handled by the video_writer,
274
+ which uses a stream writer to write to disk.
275
+ """
276
+ frame = copy.deepcopy(frame)
277
+ frame["task"] = frame.get("task", self.task)
278
+
279
+ # Convert torch to numpy if needed
280
+ for name in frame:
281
+ if isinstance(frame[name], torch.Tensor):
282
+ frame[name] = frame[name].numpy()
283
+
284
+ validate_frame(frame, self.features)
285
+
286
+ if self.episode_buffer is None:
287
+ self.episode_buffer = self.create_episode_buffer()
288
+
289
+ # Automatically add frame_index and timestamp to episode buffer
290
+ frame_index = self.episode_buffer["size"]
291
+ timestamp = frame.pop("timestamp") if "timestamp" in frame else frame_index / self.fps
292
+ self.episode_buffer["frame_index"].append(frame_index)
293
+ self.episode_buffer["timestamp"].append(timestamp)
294
+
295
+ # Add frame features to episode_buffer
296
+ for key in frame:
297
+ if key == "task":
298
+ # Note: we associate the task in natural language to its task index during `save_episode`
299
+ self.episode_buffer["task"].append(frame["task"])
300
+ continue
301
+
302
+ if key not in self.features:
303
+ raise ValueError(
304
+ f"An element of the frame is not in the features. '{key}' not in '{self.features.keys()}'."
305
+ )
306
+
307
+ if self.features[key]["dtype"] in ["image", "video"]:
308
+ img_path = self._get_image_file_path(
309
+ episode_index=self.episode_buffer["episode_index"],
310
+ image_key=key,
311
+ frame_index=frame_index,
312
+ )
313
+ if frame_index == 0:
314
+ img_path.parent.mkdir(parents=True, exist_ok=True)
315
+
316
+ # @note (k2): using our own VideoWriter class, bypassing the image_writer
317
+ self.video_writers[key].add_frame(frame[key])
318
+ self.episode_buffer[key].append(str(img_path))
319
+ else:
320
+ self.episode_buffer[key].append(frame[key])
321
+
322
+ self.episode_buffer["size"] += 1
323
+
324
+ def stop_video_writers(self):
325
+ if not hasattr(self, "video_writers"):
326
+ raise RuntimeError(
327
+ "Can't stop video writers because they haven't been initialized. Call create() first."
328
+ )
329
+ for key in self.video_writers:
330
+ self.video_writers[key].stop()
331
+
332
+ def skip_and_start_new_episode(
333
+ self,
334
+ ) -> None:
335
+ """
336
+ Skip the current episode and start a new one.
337
+ """
338
+ self.stop_video_writers()
339
+ self.episode_buffer = self.create_episode_buffer()
340
+ self.video_writers = self.create_video_writer()
341
+
342
+ # @note (k2): Code copied from LeRobotDataset.save_episode
343
+ # We override this function because we want to bypass lerobot's `compute_episode_stats` on video features
344
+ # since `compute_episode_stats` only works when images are written to disk.
345
+ def save_episode(self, episode_data: dict | None = None) -> None:
346
+ if not episode_data:
347
+ episode_buffer = self.episode_buffer
348
+
349
+ validate_episode_buffer(episode_buffer, self.meta.total_episodes, self.features)
350
+
351
+ # size and task are special cases that won't be added to hf_dataset
352
+ episode_length = episode_buffer.pop("size")
353
+ tasks = episode_buffer.pop("task")
354
+ episode_tasks = list(set(tasks))
355
+ episode_index = episode_buffer["episode_index"]
356
+
357
+ episode_buffer["index"] = np.arange(
358
+ self.meta.total_frames, self.meta.total_frames + episode_length
359
+ )
360
+ episode_buffer["episode_index"] = np.full((episode_length,), episode_index)
361
+
362
+ # Add new tasks to the tasks dictionary
363
+ for task in episode_tasks:
364
+ task_index = self.meta.get_task_index(task)
365
+ if task_index is None:
366
+ self.meta.add_task(task)
367
+
368
+ # Given tasks in natural language, find their corresponding task indices
369
+ episode_buffer["task_index"] = np.array([self.meta.get_task_index(task) for task in tasks])
370
+
371
+ for key, ft in self.features.items():
372
+ # index, episode_index, task_index are already processed above, and image and video
373
+ # are processed separately by storing image path and frame info as meta data
374
+ if key in ["index", "episode_index", "task_index"] or ft["dtype"] in ["image", "video"]:
375
+ continue
376
+ episode_buffer[key] = np.stack(episode_buffer[key])
377
+
378
+ self._wait_image_writer()
379
+ self._save_episode_table(episode_buffer, episode_index)
380
+
381
+ # @note (k2): computing only non-video features stats
382
+ non_video_features = {k: v for k, v in self.features.items() if v["dtype"] not in ["video"]}
383
+ non_vid_ep_buffer = {
384
+ k: v for k, v in episode_buffer.items() if k in non_video_features.keys()
385
+ }
386
+ ep_stats = compute_episode_stats(non_vid_ep_buffer, non_video_features)
387
+
388
+ if len(self.meta.video_keys) > 0:
389
+ video_paths = self.encode_episode_videos(episode_index)
390
+ for key in self.meta.video_keys:
391
+ episode_buffer[key] = video_paths[key]
392
+
393
+ # `meta.save_episode` be executed after encoding the videos
394
+ self.meta.save_episode(episode_index, episode_length, episode_tasks, ep_stats)
395
+
396
+ ep_data_index = get_episode_data_index(self.meta.episodes, [episode_index])
397
+ ep_data_index_np = {k: t.numpy() for k, t in ep_data_index.items()}
398
+ check_timestamps_sync(
399
+ episode_buffer["timestamp"],
400
+ episode_buffer["episode_index"],
401
+ ep_data_index_np,
402
+ self.fps,
403
+ self.tolerance_s,
404
+ )
405
+
406
+ video_files = list(self.root.rglob("*.mp4"))
407
+ assert len(video_files) == self.num_episodes * len(self.meta.video_keys)
408
+
409
+ parquet_files = list(self.root.rglob("*.parquet"))
410
+ assert len(parquet_files) == self.num_episodes
411
+
412
+ # delete images
413
+ img_dir = self.root / "images"
414
+ if img_dir.is_dir():
415
+ shutil.rmtree(self.root / "images")
416
+
417
+ if not episode_data: # Reset the buffer and create new video writers
418
+ self.episode_buffer = self.create_episode_buffer()
419
+ self.video_writers = self.create_video_writer()
420
+
421
+ # check if all video and parquet files exist
422
+ for key in self.meta.video_keys:
423
+ video_path = os.path.join(self.root, self.meta.get_video_file_path(episode_index, key))
424
+ if not os.path.exists(video_path):
425
+ raise FileNotFoundError(
426
+ f"Video path: {video_path} does not exist for episode {episode_index}"
427
+ )
428
+
429
+ parquet_path = os.path.join(self.root, self.meta.get_data_file_path(episode_index))
430
+ if not os.path.exists(parquet_path):
431
+ raise FileNotFoundError(
432
+ f"Parquet path: {parquet_path} does not exist for episode {episode_index}"
433
+ )
434
+
435
+ # @note (k2): Overriding LeRobotDataset.encode_episode_videos to use our own VideoWriter class
436
+ def encode_episode_videos(self, episode_index: int) -> dict:
437
+ video_paths = {}
438
+ for key in self.meta.video_keys:
439
+ video_paths[key] = self.video_writers[key].stop()
440
+ return video_paths
441
+
442
+ def save_episode_as_discarded(self) -> None:
443
+ """
444
+ Flag ongoing episode as discarded and save it to disk. Failed manipulations (grasp, manipulation) are
445
+ flagged as discarded. It will add the episode index to the discarded episode indices list in info.json.
446
+ """
447
+ self.meta.info["discarded_episode_indices"] = self.meta.info.get(
448
+ "discarded_episode_indices", []
449
+ ) + [self.episode_buffer["episode_index"]]
450
+ self.save_episode()
451
+
452
+
453
+ def hf_transform_to_torch_by_features(
454
+ features: datasets.Sequence, items_dict: dict[torch.Tensor | None]
455
+ ):
456
+ """Get a transform function that convert items from Hugging Face dataset (pyarrow)
457
+ to torch tensors. Importantly, images are converted from PIL, which corresponds to
458
+ a channel last representation (h w c) of uint8 type, to a torch image representation
459
+ with channel first (c h w) of float32 type in range [0,1].
460
+ """
461
+ for key in items_dict:
462
+ first_item = items_dict[key][0]
463
+ if isinstance(first_item, PILImage.Image):
464
+ to_tensor = transforms.ToTensor()
465
+ items_dict[key] = [to_tensor(img) for img in items_dict[key]]
466
+ elif first_item is None:
467
+ pass
468
+ else:
469
+ if isinstance(features[key], datasets.Value):
470
+ dtype_str = features[key].dtype
471
+ elif isinstance(features[key], datasets.Sequence):
472
+ assert isinstance(features[key].feature, datasets.Value)
473
+ dtype_str = features[key].feature.dtype
474
+ else:
475
+ raise ValueError(f"Unsupported feature type for key '{key}': {features[key]}")
476
+ dtype_mapping = {
477
+ "float32": torch.float32,
478
+ "float64": torch.float64,
479
+ "int32": torch.int32,
480
+ "int64": torch.int64,
481
+ }
482
+ items_dict[key] = [
483
+ torch.tensor(x, dtype=dtype_mapping[dtype_str]) for x in items_dict[key]
484
+ ]
485
+ return items_dict
486
+
487
+
488
+ # This is a subclass of LeRobotDataset that only fixes the data type when loading
489
+ # By default, LeRobotDataset will automatically convert float64 to float32
490
+ class TypedLeRobotDataset(LeRobotDataset):
491
+ def __init__(self, load_video=True, *args, **kwargs):
492
+ super().__init__(*args, **kwargs)
493
+ if not load_video:
494
+ video_keys = []
495
+ for key in self.meta.features.keys():
496
+ if self.meta.features[key]["dtype"] == "video":
497
+ video_keys.append(key)
498
+ for key in video_keys:
499
+ self.meta.features.pop(key)
500
+
501
+ def load_hf_dataset(self) -> datasets.Dataset:
502
+ """hf_dataset contains all the observations, states, actions, rewards, etc."""
503
+ if self.episodes is None:
504
+ path = str(self.root / "data")
505
+ hf_dataset = load_dataset("parquet", data_dir=path, split="train")
506
+ else:
507
+ files = [
508
+ str(self.root / self.meta.get_data_file_path(ep_idx)) for ep_idx in self.episodes
509
+ ]
510
+ hf_dataset = load_dataset("parquet", data_files=files, split="train")
511
+
512
+ # TODO(aliberts): hf_dataset.set_format("torch")
513
+ hf_dataset.set_transform(partial(hf_transform_to_torch_by_features, hf_dataset.features))
514
+ return hf_dataset
GR00T-WholeBodyControl/decoupled_wbc/data/utils.py ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from decoupled_wbc.control.robot_model.robot_model import RobotModel
2
+ from decoupled_wbc.data.constants import RS_VIEW_CAMERA_HEIGHT, RS_VIEW_CAMERA_WIDTH
3
+
4
+
5
+ def get_modality_config(robot_model: RobotModel, add_stereo_camera: bool = False) -> dict:
6
+ """
7
+ Get the modality config for the robot model.
8
+ """
9
+ left_hand_indices = sorted(robot_model.get_joint_group_indices("left_hand"))
10
+ right_hand_indices = sorted(robot_model.get_joint_group_indices("right_hand"))
11
+ left_arm_indices = sorted(robot_model.get_joint_group_indices("left_arm"))
12
+ right_arm_indices = sorted(robot_model.get_joint_group_indices("right_arm"))
13
+ waist_indices = sorted(robot_model.get_joint_group_indices("waist"))
14
+ left_leg_indices = sorted(robot_model.get_joint_group_indices("left_leg"))
15
+ right_leg_indices = sorted(robot_model.get_joint_group_indices("right_leg"))
16
+
17
+ modality_config = {
18
+ "state": {
19
+ "left_leg": {"start": left_leg_indices[0], "end": left_leg_indices[-1] + 1},
20
+ "right_leg": {"start": right_leg_indices[0], "end": right_leg_indices[-1] + 1},
21
+ "waist": {"start": waist_indices[0], "end": waist_indices[-1] + 1},
22
+ "left_arm": {"start": left_arm_indices[0], "end": left_arm_indices[-1] + 1},
23
+ "left_hand": {"start": left_hand_indices[0], "end": left_hand_indices[-1] + 1},
24
+ "right_arm": {"start": right_arm_indices[0], "end": right_arm_indices[-1] + 1},
25
+ "right_hand": {"start": right_hand_indices[0], "end": right_hand_indices[-1] + 1},
26
+ "left_wrist_pos": {"start": 0, "end": 3, "original_key": "observation.eef_state"},
27
+ "left_wrist_abs_quat": {
28
+ "start": 3,
29
+ "end": 7,
30
+ "original_key": "observation.eef_state",
31
+ "rotation_type": "quaternion",
32
+ },
33
+ "right_wrist_pos": {"start": 7, "end": 10, "original_key": "observation.eef_state"},
34
+ "right_wrist_abs_quat": {
35
+ "start": 10,
36
+ "end": 14,
37
+ "original_key": "observation.eef_state",
38
+ "rotation_type": "quaternion",
39
+ },
40
+ },
41
+ "action": {
42
+ "left_leg": {"start": left_leg_indices[0], "end": left_leg_indices[-1] + 1},
43
+ "right_leg": {"start": right_leg_indices[0], "end": right_leg_indices[-1] + 1},
44
+ "waist": {"start": waist_indices[0], "end": waist_indices[-1] + 1},
45
+ "left_arm": {"start": left_arm_indices[0], "end": left_arm_indices[-1] + 1},
46
+ "left_hand": {"start": left_hand_indices[0], "end": left_hand_indices[-1] + 1},
47
+ "right_arm": {"start": right_arm_indices[0], "end": right_arm_indices[-1] + 1},
48
+ "right_hand": {"start": right_hand_indices[0], "end": right_hand_indices[-1] + 1},
49
+ "left_wrist_pos": {"start": 0, "end": 3, "original_key": "action.eef"},
50
+ "left_wrist_abs_quat": {
51
+ "start": 3,
52
+ "end": 7,
53
+ "original_key": "action.eef",
54
+ "rotation_type": "quaternion",
55
+ },
56
+ "right_wrist_pos": {"start": 7, "end": 10, "original_key": "action.eef"},
57
+ "right_wrist_abs_quat": {
58
+ "start": 10,
59
+ "end": 14,
60
+ "original_key": "action.eef",
61
+ "rotation_type": "quaternion",
62
+ },
63
+ "base_height_command": {
64
+ "start": 0,
65
+ "end": 1,
66
+ "original_key": "teleop.base_height_command",
67
+ },
68
+ "navigate_command": {"start": 0, "end": 3, "original_key": "teleop.navigate_command"},
69
+ },
70
+ "video": {"ego_view": {"original_key": "observation.images.ego_view"}},
71
+ "annotation": {"human.task_description": {"original_key": "task_index"}},
72
+ }
73
+ if add_stereo_camera:
74
+ modality_config["video"].update(
75
+ {
76
+ "ego_view_left_mono": {"original_key": "observation.images.ego_view_left_mono"},
77
+ "ego_view_right_mono": {"original_key": "observation.images.ego_view_right_mono"},
78
+ }
79
+ )
80
+
81
+ return modality_config
82
+
83
+
84
+ def get_dataset_features(robot_model: RobotModel, add_stereo_camera: bool = False) -> dict:
85
+ """
86
+ Get the dataset features for the robot model.
87
+ """
88
+ dataset_features = {
89
+ "observation.images.ego_view": {
90
+ "dtype": "video",
91
+ "shape": [RS_VIEW_CAMERA_HEIGHT, RS_VIEW_CAMERA_WIDTH, 3],
92
+ "names": ["height", "width", "channel"],
93
+ },
94
+ "observation.state": {
95
+ "dtype": "float64",
96
+ "shape": (robot_model.num_joints,),
97
+ "names": robot_model.joint_names,
98
+ },
99
+ "observation.eef_state": {
100
+ "dtype": "float64",
101
+ "shape": (14,),
102
+ "names": [
103
+ "left_wrist_pos",
104
+ "left_wrist_abs_quat",
105
+ "right_wrist_pos",
106
+ "right_wrist_abs_quat",
107
+ ],
108
+ },
109
+ "action": {
110
+ "dtype": "float64",
111
+ "shape": (robot_model.num_joints,),
112
+ "names": robot_model.joint_names,
113
+ },
114
+ "action.eef": {
115
+ "dtype": "float64",
116
+ "shape": (14,),
117
+ "names": [
118
+ "left_wrist_pos",
119
+ "left_wrist_abs_quat",
120
+ "right_wrist_pos",
121
+ "right_wrist_abs_quat",
122
+ ],
123
+ },
124
+ "observation.img_state_delta": {
125
+ "dtype": "float32",
126
+ "shape": (1,),
127
+ "names": "img_state_delta",
128
+ },
129
+ "teleop.navigate_command": {
130
+ "dtype": "float64",
131
+ "shape": (3,),
132
+ "names": ["lin_vel_x", "lin_vel_y", "ang_vel_z"],
133
+ },
134
+ "teleop.base_height_command": {
135
+ "dtype": "float64",
136
+ "shape": (1,),
137
+ "names": "base_height_command",
138
+ },
139
+ }
140
+ if add_stereo_camera:
141
+ dataset_features.update(
142
+ {
143
+ "observation.images.ego_view_left_mono": {
144
+ "dtype": "video",
145
+ "shape": [RS_VIEW_CAMERA_HEIGHT, RS_VIEW_CAMERA_WIDTH, 3],
146
+ "names": ["height", "width", "channel"],
147
+ },
148
+ "observation.images.ego_view_right_mono": {
149
+ "dtype": "video",
150
+ "shape": [RS_VIEW_CAMERA_HEIGHT, RS_VIEW_CAMERA_WIDTH, 3],
151
+ "names": ["height", "width", "channel"],
152
+ },
153
+ }
154
+ )
155
+
156
+ return dataset_features
GR00T-WholeBodyControl/decoupled_wbc/data/video_writer.py ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import queue
3
+ import sys
4
+ import threading
5
+ import time
6
+
7
+ import av
8
+ import numpy as np
9
+
10
+
11
+ class VideoWriter:
12
+ def __init__(
13
+ self,
14
+ output_path: str,
15
+ width: int,
16
+ height: int,
17
+ fps: float,
18
+ codec: str = "h264",
19
+ buffer_size: int = 50,
20
+ ):
21
+ self.output_path = output_path
22
+ self._first_frame = True # Track first frame to suppress x264 info output
23
+
24
+ # Create output directory if it doesn't exist
25
+ output_dir = os.path.dirname(output_path)
26
+ if output_dir and not os.path.exists(output_dir):
27
+ os.makedirs(output_dir, exist_ok=True)
28
+
29
+ self.queue = queue.Queue(maxsize=buffer_size)
30
+ self.container = av.open(output_path, mode="w")
31
+ self.stream = self.container.add_stream(codec, rate=fps)
32
+ self.stream.width = width
33
+ self.stream.height = height
34
+ thread = threading.Thread(target=self._writer_worker, daemon=True)
35
+ thread.start()
36
+
37
+ def _assert_dimensions(self, frame: np.ndarray) -> None:
38
+ assert (
39
+ frame.shape[1] == self.stream.width and frame.shape[0] == self.stream.height
40
+ ), f"""Incorrect frame dimensions. Input dimensions: {frame.shape[1]}x{frame.shape[0]}.
41
+ Expected dimensions: {self.stream.width}x{self.stream.height}"""
42
+
43
+ def add_frame(self, frame: np.ndarray) -> None:
44
+ self._assert_dimensions(frame)
45
+ self.queue.put(frame)
46
+
47
+ def _writer_worker(self) -> None:
48
+ while True:
49
+ frame = self.queue.get()
50
+ if frame is None:
51
+ continue
52
+ self._assert_dimensions(frame)
53
+ frame = av.VideoFrame.from_ndarray(frame, format="rgb24")
54
+
55
+ # Suppress stderr for first frame encoding (x264 prints info then)
56
+ if self._first_frame:
57
+ stderr_fd = sys.stderr.fileno()
58
+ old_stderr = os.dup(stderr_fd)
59
+ devnull = os.open(os.devnull, os.O_WRONLY)
60
+ os.dup2(devnull, stderr_fd)
61
+ try:
62
+ packets = self.stream.encode(frame)
63
+ for packet in packets:
64
+ self.container.mux(packet)
65
+ finally:
66
+ os.dup2(old_stderr, stderr_fd)
67
+ os.close(old_stderr)
68
+ os.close(devnull)
69
+ self._first_frame = False
70
+ else:
71
+ packets = self.stream.encode(frame)
72
+ for packet in packets:
73
+ self.container.mux(packet)
74
+
75
+ def _flush_stream(self) -> None:
76
+ packets = self.stream.encode()
77
+ for packet in packets:
78
+ self.container.mux(packet)
79
+
80
+ def stop(self) -> str:
81
+ """
82
+ Blocking call. Waits until all the frames in the queue have been written to the file
83
+ and the video writer has been closed.
84
+ """
85
+ if not self.queue.empty():
86
+ print("Waiting for video writer queue to empty...")
87
+ while not self.queue.empty():
88
+ time.sleep(0.1)
89
+
90
+ print("Video writer queue is empty, flushing stream...")
91
+ self._flush_stream()
92
+ self.container.close()
93
+ return self.output_path
94
+
95
+ def cancel(self) -> None:
96
+ """Immediately stops writing and deletes the output file"""
97
+ if os.path.exists(self.output_path):
98
+ os.remove(self.output_path)
99
+ self.container.close()
100
+
101
+ def __del__(self) -> None:
102
+ self.container.close()
GR00T-WholeBodyControl/decoupled_wbc/data/viz/rerun_viz.py ADDED
@@ -0,0 +1,213 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Rerun visualization utilities for plotting data and images."""
3
+
4
+ from __future__ import annotations
5
+
6
+ import argparse
7
+ import time
8
+ from typing import Dict, List, Optional
9
+
10
+ import cv2
11
+ import numpy as np
12
+ import rerun as rr # pip install rerun-sdk
13
+ import rerun.blueprint as rrb
14
+
15
+
16
+ class RerunViz:
17
+ """Class for visualizing data using Rerun."""
18
+
19
+ def __init__(
20
+ self,
21
+ image_keys: List[str],
22
+ tensor_keys: List[str],
23
+ app_name: str = "rerun_visualization",
24
+ memory_limit: str = "1GB",
25
+ window_size: float = 5.0,
26
+ port: int = 9876,
27
+ in_docker: bool = False,
28
+ ):
29
+ """Initialize the RerunViz class.
30
+ Args:
31
+ app_name: Name of the Rerun application
32
+ memory_limit: Memory limit for Rerun
33
+ window_size: Size of the time window in seconds
34
+ image_keys: List of image keys to plot
35
+ tensor_keys: List of tensor keys to plot
36
+ in_docker: Whether running inside Docker container. If in docker,
37
+ forward data to outside of the container to be rendered.
38
+ Use `rerun --port 9876` to visualize. Expecting rerun-cli 0.22.1 outside of docker.
39
+ Tested with rerun-sdk 0.21.0 inside docker.
40
+ """
41
+ self.app_name = app_name
42
+ self.memory_limit = memory_limit
43
+ self.window_size = window_size
44
+ self.tensor_keys = tensor_keys
45
+ self.image_keys = image_keys
46
+ self.port = port
47
+ self.in_docker = in_docker
48
+ # Initialize Rerun
49
+ self._initialize_rerun()
50
+
51
+ def _initialize_rerun(self):
52
+ """Initialize Rerun and set up the blueprint."""
53
+ rr.init(self.app_name)
54
+ if not self.in_docker:
55
+ # support for web visualization
56
+ rr.spawn(memory_limit=self.memory_limit, port=self.port, connect=True)
57
+ else:
58
+ # forward data to outside of the docker container
59
+ rr.connect(f"127.0.0.1:{self.port}")
60
+ self._create_blueprint()
61
+
62
+ def _create_blueprint(self):
63
+ # Create a grid of plots
64
+ contents = []
65
+
66
+ # Add time series plots
67
+ for tensor_key in self.tensor_keys:
68
+ contents.append(
69
+ rrb.TimeSeriesView(
70
+ origin=tensor_key,
71
+ time_ranges=[
72
+ rrb.VisibleTimeRange(
73
+ "time",
74
+ start=rrb.TimeRangeBoundary.cursor_relative(seconds=-self.window_size),
75
+ end=rrb.TimeRangeBoundary.cursor_relative(),
76
+ )
77
+ ],
78
+ )
79
+ )
80
+
81
+ # Add image views
82
+ for image_key in self.image_keys:
83
+ contents.append(rrb.Spatial2DView(origin=image_key, name=image_key))
84
+
85
+ # Send the blueprint with collapsed panels to hide side/bottom bars
86
+ rr.send_blueprint(rrb.Blueprint(rrb.Grid(contents=contents), collapse_panels=True))
87
+
88
+ def set_rerun_keys(self, image_keys: List[str], tensor_keys: List[str]):
89
+ """Set the Rerun keys."""
90
+ self.image_keys = image_keys
91
+ self.tensor_keys = tensor_keys
92
+ self._create_blueprint()
93
+
94
+ def plot_images(self, images: Dict[str, np.ndarray], timestamp: Optional[float] = None):
95
+ """Plot image data.
96
+
97
+ Args:
98
+ images: Dictionary mapping image names to image data
99
+ timestamp: Timestamp for the data (if None, uses current time)
100
+ """
101
+ if timestamp is None:
102
+ timestamp = time.time()
103
+
104
+ rr.set_time_seconds("time", timestamp)
105
+
106
+ for key, image in images.items():
107
+ if image is None:
108
+ continue
109
+
110
+ if "depth" in key:
111
+ # Color jet
112
+ depth_colormap = cv2.applyColorMap(
113
+ cv2.convertScaleAbs(image, alpha=0.03), cv2.COLORMAP_JET
114
+ )
115
+ rr.log(f"{key}", rr.Image(depth_colormap))
116
+ else:
117
+ # Convert to RGB
118
+ # image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
119
+ rr.log(f"{key}", rr.Image(image))
120
+
121
+ def plot_tensors(
122
+ self, data: Optional[Dict[str, np.ndarray]] = None, timestamp: Optional[float] = None
123
+ ):
124
+ """Plot tensor data.
125
+
126
+ Args:
127
+ data: Dictionary mapping keys to tensor values
128
+ timestamp: Timestamp for the data (if None, uses current time)
129
+ """
130
+ if timestamp is None:
131
+ timestamp = time.time()
132
+
133
+ rr.set_time_seconds("time", timestamp)
134
+
135
+ # If no data provided, use random walk generators
136
+ for tensor_key in self.tensor_keys:
137
+ for i in range(data[tensor_key].shape[0]):
138
+ rr.log(f"{tensor_key}/{i}", rr.Scalar(data[tensor_key][i]))
139
+
140
+ def close(self):
141
+ """Close the RerunViz instance."""
142
+ rr.rerun_shutdown()
143
+
144
+
145
+ if __name__ == "__main__":
146
+ """Main function to demonstrate the RerunViz class."""
147
+ parser = argparse.ArgumentParser(description="Plot dashboard stress test")
148
+ parser.add_argument(
149
+ "--freq", type=float, default=20, help="Frequency of logging (applies to all series)"
150
+ )
151
+ parser.add_argument(
152
+ "--window-size", type=float, default=5.0, help="Size of the window in seconds"
153
+ )
154
+ parser.add_argument("--duration", type=float, default=60, help="How long to log for in seconds")
155
+ parser.add_argument("--use-rs", action="store_true", help="Use RealSense sensor")
156
+ parser.add_argument("--use-zed", action="store_true", help="Use ZED sensor")
157
+ parser.add_argument("--in-docker", action="store_true", help="Running inside Docker container")
158
+ args = parser.parse_args()
159
+
160
+ if args.use_rs:
161
+ image_keys = ["color_image", "depth_image"]
162
+ from decoupled_wbc.control.sensor.realsense import RealSenseClientSensor
163
+
164
+ sensor = RealSenseClientSensor()
165
+ elif args.use_zed:
166
+ image_keys = ["left_image", "right_image"]
167
+ from decoupled_wbc.control.sensor.zed import ZEDClientSensor
168
+
169
+ sensor = ZEDClientSensor()
170
+ else:
171
+ from decoupled_wbc.control.sensor.dummy import DummySensor
172
+
173
+ sensor = DummySensor()
174
+ image_keys = ["color_image"]
175
+
176
+ tensor_keys = ["left_arm_qpos", "left_hand_qpos", "right_arm_qpos", "right_hand_qpos"]
177
+
178
+ # Initialize the RerunViz class
179
+ viz = RerunViz(
180
+ image_keys=image_keys,
181
+ tensor_keys=tensor_keys,
182
+ window_size=args.window_size,
183
+ in_docker=args.in_docker,
184
+ )
185
+
186
+ # Run the visualization loop
187
+ cur_time = time.time()
188
+ end_time = cur_time + args.duration
189
+ time_per_tick = 1.0 / args.freq
190
+
191
+ while cur_time < end_time:
192
+ # Advance time and sleep if necessary
193
+ cur_time += time_per_tick
194
+ sleep_for = cur_time - time.time()
195
+ if sleep_for > 0:
196
+ time.sleep(sleep_for)
197
+
198
+ if sleep_for < -0.1:
199
+ print(f"Warning: missed logging window by {-sleep_for:.2f} seconds")
200
+
201
+ # Plot dummy tensor
202
+ dummy_tensor = np.random.randn(5)
203
+ dummy_tensor_dict = {key: dummy_tensor for key in tensor_keys}
204
+
205
+ viz.plot_tensors(dummy_tensor_dict, cur_time)
206
+
207
+ # Plot images if available
208
+ images = sensor.read()
209
+ if images is not None:
210
+ img_to_show = {key: images[key] for key in image_keys}
211
+ viz.plot_images(img_to_show, cur_time)
212
+
213
+ rr.script_teardown(args)
GR00T-WholeBodyControl/decoupled_wbc/docker/.bashrc ADDED
@@ -0,0 +1,163 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ~/.bashrc: executed by bash(1) for non-login shells.
2
+ # see /usr/share/doc/bash/examples/startup-files (in the package bash-doc)
3
+ # for examples
4
+
5
+ # If not running interactively, don't do anything
6
+ case $- in
7
+ *i*) ;;
8
+ *) return;;
9
+ esac
10
+
11
+ # don't put duplicate lines or lines starting with space in the history.
12
+ # See bash(1) for more options
13
+ HISTCONTROL=ignoreboth
14
+
15
+ # append to the history file, don't overwrite it
16
+ shopt -s histappend
17
+
18
+ # for setting history length see HISTSIZE and HISTFILESIZE in bash(1)
19
+ HISTSIZE=1000
20
+ HISTFILESIZE=2000
21
+
22
+ # check the window size after each command and, if necessary,
23
+ # update the values of LINES and COLUMNS.
24
+ shopt -s checkwinsize
25
+
26
+ # If set, the pattern "**" used in a pathname expansion context will
27
+ # match all files and zero or more directories and subdirectories.
28
+ #shopt -s globstar
29
+
30
+ # make less more friendly for non-text input files, see lesspipe(1)
31
+ [ -x /usr/bin/lesspipe ] && eval "$(SHELL=/bin/sh lesspipe)"
32
+
33
+ # set variable identifying the chroot you work in (used in the prompt below)
34
+ if [ -z "${debian_chroot:-}" ] && [ -r /etc/debian_chroot ]; then
35
+ debian_chroot=$(cat /etc/debian_chroot)
36
+ fi
37
+
38
+ # set a fancy prompt (non-color, unless we know we "want" color)
39
+ case "$TERM" in
40
+ xterm-color|*-256color) color_prompt=yes;;
41
+ esac
42
+
43
+ # uncomment for a colored prompt, if the terminal has the capability; turned
44
+ # off by default to not distract the user: the focus in a terminal window
45
+ # should be on the output of commands, not on the prompt
46
+ force_color_prompt=yes
47
+
48
+ if [ -n "$force_color_prompt" ]; then
49
+ if [ -x /usr/bin/tput ] && tput setaf 1 >&/dev/null; then
50
+ # We have color support; assume it's compliant with Ecma-48
51
+ # (ISO/IEC-6429). (Lack of such support is extremely rare, and such
52
+ # a case would tend to support setf rather than setaf.)
53
+ color_prompt=yes
54
+ else
55
+ color_prompt=
56
+ fi
57
+ fi
58
+
59
+ if [ "$color_prompt" = yes ]; then
60
+ PS1='${debian_chroot:+($debian_chroot)}\[\033[01;32m\]\u@\h\[\033[00m\]:\[\033[01;34m\]\w\[\033[00m\]\$ '
61
+ else
62
+ PS1='${debian_chroot:+($debian_chroot)}\u@\h:\w\$ '
63
+ fi
64
+ unset color_prompt force_color_prompt
65
+
66
+ # If this is an xterm set the title to user@host:dir
67
+ case "$TERM" in
68
+ xterm*|rxvt*)
69
+ PS1="\[\e]0;${debian_chroot:+($debian_chroot)}\u@\h: \w\a\]$PS1"
70
+ ;;
71
+ *)
72
+ ;;
73
+ esac
74
+
75
+ # enable color support of ls and also add handy aliases
76
+ if [ -x /usr/bin/dircolors ]; then
77
+ test -r ~/.dircolors && eval "$(dircolors -b ~/.dircolors)" || eval "$(dircolors -b)"
78
+ alias ls='ls --color=auto'
79
+ #alias dir='dir --color=auto'
80
+ #alias vdir='vdir --color=auto'
81
+
82
+ alias grep='grep --color=auto'
83
+ alias fgrep='fgrep --color=auto'
84
+ alias egrep='egrep --color=auto'
85
+ fi
86
+
87
+ # colored GCC warnings and errors
88
+ export GCC_COLORS='error=01;31:warning=01;35:note=01;36:caret=01;32:locus=01:quote=01'
89
+
90
+ # Set terminal type for color support
91
+ export TERM=xterm-256color
92
+
93
+ # some more ls aliases
94
+ alias ll='ls -alF'
95
+ alias la='ls -A'
96
+ alias l='ls -CF'
97
+
98
+ # Add an "alert" alias for long running commands. Use like so:
99
+ # sleep 10; alert
100
+ alias alert='notify-send --urgency=low -i "$([ $? = 0 ] && echo terminal || echo error)" "$(history|tail -n1|sed -e '\''s/^\s*[0-9]\+\s*//;s/[;&|]\s*alert$//'\'')"'
101
+
102
+ # Alias definitions.
103
+ # You may want to put all your additions into a separate file like
104
+ # ~/.bash_aliases, instead of adding them here directly.
105
+ # See /usr/share/doc/bash-doc/examples in the bash-doc package.
106
+
107
+ if [ -f ~/.bash_aliases ]; then
108
+ . ~/.bash_aliases
109
+ fi
110
+
111
+ # enable programmable completion features (you don't need to enable
112
+ # this, if it's already enabled in /etc/bash.bashrc and /etc/profile
113
+ # sources /etc/bash.bashrc).
114
+ if ! shopt -oq posix; then
115
+ if [ -f /usr/share/bash-completion/bash_completion ]; then
116
+ . /usr/share/bash-completion/bash_completion
117
+ elif [ -f /etc/bash_completion ]; then
118
+ . /etc/bash_completion
119
+ fi
120
+ fi
121
+
122
+ # useful commands
123
+ bind '"\e[A": history-search-backward'
124
+ bind '"\e[B": history-search-forward'
125
+
126
+ # Store the last 10 directories in a history file
127
+ CD_HISTFILE=~/.cd_history
128
+ CD_HISTSIZE=10
129
+
130
+ cd() {
131
+ local histfile="${CD_HISTFILE:-$HOME/.cd_history}"
132
+ local max="${CD_HISTSIZE:-10}"
133
+
134
+ case "$1" in
135
+ --) [ -f "$histfile" ] && tac "$histfile" | nl -w2 -s' ' || echo "No directory history yet."; return ;;
136
+ -[0-9]*)
137
+ local idx=${1#-}
138
+ local dir=$(tac "$histfile" 2>/dev/null | sed -n "${idx}p")
139
+ [ -n "$dir" ] && builtin cd "$dir" || echo "Invalid selection: $1"
140
+ return ;;
141
+ esac
142
+
143
+ builtin cd "$@" || return
144
+
145
+ [[ $(tail -n1 "$histfile" 2>/dev/null) != "$PWD" ]] && echo "$PWD" >> "$histfile"
146
+ tail -n "$max" "$histfile" > "${histfile}.tmp" && mv "${histfile}.tmp" "$histfile"
147
+ }
148
+
149
+ # Make decoupled_wbc importable
150
+ export PYTHONPATH="${DECOUPLED_WBC_DIR}:${PYTHONPATH}"
151
+
152
+ # Manus to LD_LIBRARY_PATH
153
+ export LD_LIBRARY_PATH=$DECOUPLED_WBC_DIR/decoupled_wbc/control/teleop/device/SDKClient_Linux/ManusSDK/lib:$LD_LIBRARY_PATH
154
+
155
+ # CUDA support
156
+ export LD_LIBRARY_PATH=/usr/lib/x86_64-linux-gnu:/usr/lib:/lib/x86_64-linux-gnu:/lib64:/lib:$LD_LIBRARY_PATH
157
+
158
+ # decoupled_wbc aliases
159
+ alias dg="python $DECOUPLED_WBC_DIR/decoupled_wbc/scripts/deploy_g1.py"
160
+ alias rsl="python $DECOUPLED_WBC_DIR/decoupled_wbc/control/main/teleop/run_sim_loop.py"
161
+ alias rgcl="python $DECOUPLED_WBC_DIR/decoupled_wbc/control/main/teleop/run_g1_control_loop.py"
162
+ alias rtpl="python $DECOUPLED_WBC_DIR/decoupled_wbc/control/main/teleop/run_teleop_policy_loop.py"
163
+ alias tgcl="pytest $DECOUPLED_WBC_DIR/decoupled_wbc/tests/control/main/teleop/test_g1_control_loop.py -s"
GR00T-WholeBodyControl/decoupled_wbc/docker/.tmux.conf ADDED
@@ -0,0 +1,84 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Enable mouse mode
2
+ set -g mouse on
3
+
4
+ # Start window numbering at 0 (default)
5
+ set -g base-index 0
6
+ setw -g pane-base-index 0
7
+
8
+ # Increase scrollback buffer size
9
+ set -g history-limit 50000
10
+
11
+ # Use Alt-arrow keys without prefix key to switch panes
12
+ bind -n M-Left select-pane -L
13
+ bind -n M-Right select-pane -R
14
+ bind -n M-Up select-pane -U
15
+ bind -n M-Down select-pane -D
16
+
17
+ # Use Alt-1,2,3... to switch windows
18
+ bind -n M-1 select-window -t 1
19
+ bind -n M-2 select-window -t 2
20
+ bind -n M-3 select-window -t 3
21
+ bind -n M-4 select-window -t 4
22
+ bind -n M-5 select-window -t 5
23
+ bind -n M-6 select-window -t 6
24
+ bind -n M-7 select-window -t 7
25
+ bind -n M-8 select-window -t 8
26
+ bind -n M-9 select-window -t 9
27
+
28
+ # Split panes using Alt-| and Alt--
29
+ bind -n M-| split-window -h
30
+ bind -n M-- split-window -v
31
+
32
+ # Easy config reload
33
+ bind -n M-r source-file ~/.tmux.conf \; display-message "Config reloaded!"
34
+
35
+ # Status bar customization
36
+ set -g status-style bg=colour240,fg=colour255
37
+ set -g status-left "#[fg=colour255,bg=colour240] #S #[fg=colour240,bg=colour238]"
38
+ set -g status-right "#[fg=colour255,bg=colour240] %H:%M #[fg=colour240,bg=colour238]"
39
+
40
+ # Window status format
41
+ setw -g window-status-format "#[fg=colour255,bg=colour238] #I:#W "
42
+ setw -g window-status-current-format "#[fg=colour238,bg=colour255]#[fg=colour238,bg=colour255] #I:#W #[fg=colour255,bg=colour238]"
43
+
44
+ # Pane border colors
45
+ set -g pane-border-style fg=colour240
46
+ set -g pane-active-border-style fg=colour255
47
+
48
+ # Message text
49
+ set -g message-style bg=colour238,fg=colour255
50
+
51
+ # Clock mode
52
+ setw -g clock-mode-colour colour255
53
+
54
+ # Enable focus events
55
+ set -g focus-events on
56
+
57
+ # Increase escape time
58
+ set -sg escape-time 0
59
+
60
+ # Enable true color support
61
+ set -ga terminal-overrides ",*256col*:Tc"
62
+
63
+ # Set default terminal mode to 256 colors
64
+ set -g default-terminal "screen-256color"
65
+
66
+ # Display a message when a window is created
67
+ set -g display-time 4000
68
+
69
+ # Automatically set window title
70
+ setw -g automatic-rename on
71
+ set -g set-titles on
72
+ set -g set-titles-string "#T"
73
+
74
+ # Enable clipboard integration
75
+ set -g @plugin 'tmux-plugins/tmux-yank'
76
+
77
+ # List of plugins
78
+ set -g @plugin 'tmux-plugins/tpm'
79
+ set -g @plugin 'tmux-plugins/tmux-sensible'
80
+ set -g @plugin 'tmux-plugins/tmux-resurrect'
81
+ set -g @plugin 'tmux-plugins/tmux-continuum'
82
+
83
+ # Initialize TMUX plugin manager (keep this line at the very bottom of tmux.conf)
84
+ run '~/.tmux/plugins/tpm/tpm'
GR00T-WholeBodyControl/decoupled_wbc/docker/70-manus-hid.rules ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ # HIDAPI/libusb
2
+ SUBSYSTEMS=="usb", ATTRS{idVendor}=="3325", MODE:="0666"
3
+
4
+ # HIDAPI/hidraw
5
+ KERNEL=="hidraw*", ATTRS{idVendor}=="3325", MODE:="0666"
GR00T-WholeBodyControl/decoupled_wbc/docker/Dockerfile.deploy ADDED
@@ -0,0 +1,130 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM nvgear/ros-2:latest
2
+
3
+ # Accept build argument for username
4
+ ARG USERNAME
5
+ ARG USERID
6
+ ARG HOME_DIR
7
+ ARG WORKTREE_NAME
8
+
9
+ # Create user with the same name as host
10
+ RUN if [ "$USERID" != "0" ]; then \
11
+ useradd -m -u ${USERID} -s /bin/bash ${USERNAME} && \
12
+ echo "${USERNAME} ALL=(ALL) NOPASSWD: ALL" >> /etc/sudoers && \
13
+ # Add user to video and render groups for GPU access
14
+ usermod -a -G video,render ${USERNAME} || true; \
15
+ fi
16
+
17
+ # Copy .bashrc with color settings before switching user
18
+ COPY --chown=${USERNAME}:${USERNAME} decoupled_wbc/docker/.bashrc ${HOME_DIR}/.bashrc
19
+
20
+ # Install Manus udev rules
21
+ COPY --chown=${USERNAME}:${USERNAME} decoupled_wbc/docker/70-manus-hid.rules /etc/udev/rules.d/70-manus-hid.rules
22
+
23
+ # Copy tmux configuration
24
+ COPY --chown=${USERNAME}:${USERNAME} decoupled_wbc/docker/.tmux.conf ${HOME_DIR}/.tmux.conf
25
+
26
+ # Switch to user
27
+ USER ${USERNAME}
28
+
29
+ # Install tmux plugin manager and uv in parallel
30
+ RUN git clone https://github.com/tmux-plugins/tpm ${HOME_DIR}/.tmux/plugins/tpm & \
31
+ curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=${HOME_DIR}/.cargo/bin sh & \
32
+ wait
33
+
34
+ # Install tmux plugins automatically
35
+ RUN ${HOME_DIR}/.tmux/plugins/tpm/bin/install_plugins || true
36
+
37
+ # Add uv to PATH
38
+ ENV PATH="${HOME_DIR}/.cargo/bin:$PATH"
39
+ ENV UV_PYTHON=${HOME_DIR}/venv/bin/python
40
+
41
+ # Create venv
42
+ RUN uv venv --python 3.10 ${HOME_DIR}/venv
43
+
44
+ # Install hardware-specific packages (x86 only - not available on ARM64/Orin)
45
+ USER root
46
+ COPY --chown=${USERNAME}:${USERNAME} decoupled_wbc/control/teleop/device/pico/XRoboToolkit_PC_Service_1.0.0_ubuntu_22.04_amd64.deb ${HOME_DIR}/XRoboToolkit_PC_Service_1.0.0_ubuntu_22.04_amd64.deb
47
+ COPY --chown=${USERNAME}:${USERNAME} decoupled_wbc/control/teleop/device/pico/roboticsservice_1.0.0.0_arm64.deb ${HOME_DIR}/roboticsservice_1.0.0.0_arm64.deb
48
+
49
+ RUN if [ "$(dpkg --print-architecture)" = "amd64" ]; then \
50
+ # Ultra Leap setup
51
+ wget -qO - https://repo.ultraleap.com/keys/apt/gpg | gpg --dearmor | tee /etc/apt/trusted.gpg.d/ultraleap.gpg && \
52
+ echo 'deb [arch=amd64] https://repo.ultraleap.com/apt stable main' | tee /etc/apt/sources.list.d/ultraleap.list && \
53
+ apt-get update && \
54
+ echo "yes" | DEBIAN_FRONTEND=noninteractive apt-get install -y ultraleap-hand-tracking libhidapi-dev && \
55
+ # Space Mouse udev rules
56
+ echo 'KERNEL=="hidraw*", SUBSYSTEM=="hidraw", MODE="0664", GROUP="plugdev"' > /etc/udev/rules.d/99-hidraw-permissions.rules && \
57
+ usermod -aG plugdev ${USERNAME}; \
58
+ # Pico setup
59
+ apt-get install -y xdg-utils && \
60
+ dpkg -i ${HOME_DIR}/XRoboToolkit_PC_Service_1.0.0_ubuntu_22.04_amd64.deb; \
61
+ else \
62
+ echo "Skipping x86-only hardware packages on $(dpkg --print-architecture)"; \
63
+ fi
64
+
65
+ USER ${USERNAME}
66
+ # Install hardware Python packages (x86 only) with caching
67
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
68
+ if [ "$(dpkg --print-architecture)" = "amd64" ]; then \
69
+ # Ultra Leap Python bindings
70
+ git clone https://github.com/ultraleap/leapc-python-bindings ${HOME_DIR}/leapc-python-bindings && \
71
+ cd ${HOME_DIR}/leapc-python-bindings && \
72
+ UV_CONCURRENT_DOWNLOADS=8 uv pip install -r requirements.txt && \
73
+ MAKEFLAGS="-j$(nproc)" ${HOME_DIR}/venv/bin/python -m build leapc-cffi && \
74
+ uv pip install leapc-cffi/dist/leapc_cffi-0.0.1.tar.gz && \
75
+ uv pip install -e leapc-python-api && \
76
+ # Space Mouse Python package
77
+ uv pip install pyspacemouse && \
78
+ # Pico Python bindings
79
+ git clone https://github.com/XR-Robotics/XRoboToolkit-PC-Service-Pybind.git ${HOME_DIR}/XRoboToolkit-PC-Service-Pybind && \
80
+ cd ${HOME_DIR}/XRoboToolkit-PC-Service-Pybind && \
81
+ uv pip install setuptools pybind11 && \
82
+ sed -i "s|pip install|uv pip install|g" setup_ubuntu.sh && \
83
+ sed -i "s|pip uninstall|uv pip uninstall|g" setup_ubuntu.sh && \
84
+ sed -i "s|python setup.py install|${HOME_DIR}/venv/bin/python setup.py install|g" setup_ubuntu.sh && \
85
+ bash setup_ubuntu.sh; \
86
+ fi
87
+
88
+ # Install Python dependencies using uv with caching
89
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
90
+ UV_CONCURRENT_DOWNLOADS=8 uv pip install --upgrade pip ipython jupyter notebook debugpy
91
+
92
+
93
+ # Copy entire project to the workspace directory where it will be mounted at runtime
94
+ # NOTE: The build context must be the project root for this to work
95
+ # Use dynamic worktree name to match runtime mount path
96
+ COPY --chown=${USERNAME}:${USERNAME} . ${HOME_DIR}/Projects/${WORKTREE_NAME}
97
+
98
+ # Install Python dependencies inside the venv with caching - split into separate commands
99
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
100
+ UV_CONCURRENT_DOWNLOADS=8 uv pip install \
101
+ -e ${HOME_DIR}/Projects/${WORKTREE_NAME}/external_dependencies/unitree_sdk2_python
102
+
103
+ # Unlike pip, uv downloads LFS files by default. There's a bug in uv that causes LFS files
104
+ # to fail to download (https://github.com/astral-sh/uv/issues/3312). So we need to set
105
+ # UV_GIT_LFS=1 to prevent uv from downloading LFS files.
106
+ # Install project packages (decoupled_wbc + gear_sonic) with caching
107
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
108
+ GIT_LFS_SKIP_SMUDGE=1 UV_CONCURRENT_DOWNLOADS=8 uv pip install \
109
+ -e "${HOME_DIR}/Projects/${WORKTREE_NAME}/decoupled_wbc[full,dev]" \
110
+ -e "${HOME_DIR}/Projects/${WORKTREE_NAME}/gear_sonic[sim]"
111
+
112
+ # Clone and install robosuite with specific branch
113
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
114
+ git clone https://github.com/xieleo5/robosuite.git ${HOME_DIR}/robosuite && \
115
+ cd ${HOME_DIR}/robosuite && \
116
+ git checkout leo/support_g1_locomanip && \
117
+ UV_CONCURRENT_DOWNLOADS=8 uv pip install -e .
118
+
119
+ # Install gr00trobocasa
120
+ RUN --mount=type=cache,target=${HOME_DIR}/.cache/uv,uid=${USERID},gid=${USERID} \
121
+ UV_CONCURRENT_DOWNLOADS=8 uv pip install -e ${HOME_DIR}/Projects/${WORKTREE_NAME}/decoupled_wbc/dexmg/gr00trobocasa
122
+
123
+ # Configure bash environment with virtual environment and ROS2 setup
124
+ RUN echo "source ${HOME_DIR}/venv/bin/activate" >> ${HOME_DIR}/.bashrc && \
125
+ echo "source /opt/ros/humble/setup.bash" >> ${HOME_DIR}/.bashrc && \
126
+ echo "export ROS_LOCALHOST_ONLY=1" >> ${HOME_DIR}/.bashrc && \
127
+ echo "export PYTHONPATH=${HOME_DIR}/Projects/${WORKTREE_NAME}:\${PYTHONPATH}" >> ${HOME_DIR}/.bashrc
128
+
129
+ # Default command (can be overridden at runtime)
130
+ CMD ["/bin/bash"]