Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- GR00T-WholeBodyControl/.github/workflows/docs.yml +74 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/base/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/base/env.py +45 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/base/humanoid_env.py +60 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/base/policy.py +47 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/base/sensor.py +35 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/envs/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/main/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/main/config_template.py +45 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/main/constants.py +16 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_decoupled_whole_body_policy.py +157 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/g1_gear_wbc_policy.py +295 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/identity_policy.py +25 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/interpolation_policy.py +297 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/keyboard_navigation_policy.py +87 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/lerobot_replay_policy.py +111 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/teleop_policy.py +207 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/policy/wbc_policy_factory.py +65 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/__init__.py +3 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/robot_model/robot_model.py +772 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/sensor/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/sensor/composed_camera.py +440 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/sensor/oak.py +324 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/sensor/sensor_server.py +128 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_retargeting_ik.py +148 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/teleop/teleop_streamer.py +240 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/__init__.py +0 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/cv_bridge.py +396 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/episode_state.py +32 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/gear_wbc_utils.py +100 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/keyboard_dispatcher.py +255 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/network_utils.py +137 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/ros_utils.py +201 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/run_real_checklist.py +121 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/service.py +182 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/term_color_constants.py +19 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/utils/text_to_speech.py +28 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/visualization/humanoid_visualizer.py +52 -0
- GR00T-WholeBodyControl/decoupled_wbc/control/visualization/meshcat_visualizer_env.py +77 -0
- GR00T-WholeBodyControl/decoupled_wbc/data/constants.py +5 -0
- GR00T-WholeBodyControl/decoupled_wbc/data/exporter.py +514 -0
- GR00T-WholeBodyControl/decoupled_wbc/data/utils.py +156 -0
- GR00T-WholeBodyControl/decoupled_wbc/data/video_writer.py +102 -0
- GR00T-WholeBodyControl/decoupled_wbc/data/viz/rerun_viz.py +213 -0
- GR00T-WholeBodyControl/decoupled_wbc/docker/.bashrc +163 -0
- GR00T-WholeBodyControl/decoupled_wbc/docker/.tmux.conf +84 -0
- GR00T-WholeBodyControl/decoupled_wbc/docker/70-manus-hid.rules +5 -0
- 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"]
|