diff --git a/README.md b/README.md
index c884d67..51260b1 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
UnifoLM-WMA-0 is Unitreeβs open-source world-modelβaction architecture spanning multiple types of robotic embodiments, designed specifically for general-purpose robot learning. Its core component is a world-model capable of understanding the physical interactions between robots and the environments. This world-model provides two key functions: (a) Simulation Engine β operates as an interactive simulator to generate synthetic data for robot learning; (b) Policy Enhancement β connects with an action head and, by predicting future interaction processes with the world-model, further optimizes decision-making performance.
-## π¦Ύ Real Robot Deployment
+## π¦Ύ Real-Robot Demonstrations
|
|
|
|:---:|:---:|
|
|
|
@@ -22,13 +22,14 @@
## π₯ News
-* Sep 15, 2025: π We released the training and inference code along with the model weights of **UnifoLM-WMA-0**.
+* Sep 22, 2025: π We released the deployment code for assisting experiments with [Unitree](https://www.unitree.com/) robots.
+* Sep 15, 2025: π We released the training and inference code along with the model weights of [**UnifoLM-WMA-0**](https://huggingface.co/collections/unitreerobotics/unifolm-wma-0-68ca23027310c0ca0f34959c).
## π Opensource Plan
- [x] Training
- [x] Inference
- [x] Checkpoints
-- [ ] Deployment
+- [x] Deployment
## βοΈ Installation
```
@@ -140,7 +141,7 @@ B. To conduct training on a single or multiple datasets, please follow the steps
```
bash scripts/train.sh
```
-## π Inference under the Interactive Simulation Mode
+## π Inference under Interactive Simulation Mode
To run the world model in an interactive simulation mode, follow these steps:
- **Step 1**: (Skip this step if you just would like to test using the examples we provided) Prepare your own prompt following the format used in the [examples/world_model_interaction_prompts](https://github.com/unitreerobotics/unitree-world-model/tree/main/examples/world_model_interaction_prompts):
```
@@ -166,7 +167,33 @@ To run the world model in an interactive simulation mode, follow these steps:
```
bash scripts/run_world_model_interaction.sh
```
-
+
+## π§ Inference and Deployment under Decision-Making Mode
+
+In this setup, inference is performed on a server, while a robot client gathers observations from the real-robot and sends them to the server to query actions. The process unfolds through the following steps:
+
+### Server Setup:
+- **Step-1**: Specify ```ckpt```, ```res_dir```, ```datasets``` in scripts/run_real_eval_server.sh;
+- **Step-2**: Configure ```data_dir``` and ```dataset_and_weights``` in config/inference/world_model_decision_making.yaml;
+- **Step-3**: Launch the server:
+```
+conda activate unifolm-wma
+cd unifolm-world-model-action
+bash scripts/run_real_eval_server.sh
+```
+
+### Client Setup
+- **Step-1**: Follow the instructions in [unitree_deploy/README.md](https://github.com/unitreerobotics/unitree-world-model/blob/main/unitree_deploy/README.md) to create create the ```unitree_deploy``` conda environment, install the required packages, lanuch the controllers or services on the real-robot.
+- **Step-2**: Open a new terminal and establish a tunnel connection from the client to the server:
+```
+ssh user_name@remote_server_IP -CNg -L 8000:127.0.0.1:8000
+```
+- **Step-3**: Run the ```robot_client.py``` script to start inference:
+```
+cd unitree_deploy
+python scripts/robot_client.py --robot_type "g1_dex1" --action_horizon 16 --exe_steps 16 --observation_horizon 2 --language_instruction "pack black camera into box" --output_dir ./results --control_freq 15
+```
+
## π Codebase Architecture
Here's a high-level overview of the project's code structure and core components:
```
@@ -185,6 +212,7 @@ unitree-world-model/
β β βββ models # Model architectures and backbone definitions
β β βββ modules # Custom model modules and components
β β βββ utils # Utility functions and common helpers
+ βββ unitree_deploy # Depolyment code
```
## π Acknowledgement
diff --git a/configs/inference/world_model_decision_making.yaml b/configs/inference/world_model_decision_making.yaml
new file mode 100644
index 0000000..2f51dce
--- /dev/null
+++ b/configs/inference/world_model_decision_making.yaml
@@ -0,0 +1,240 @@
+model:
+ target: unifolm_wma.models.ddpms.LatentVisualDiffusion
+ params:
+ rescale_betas_zero_snr: True
+ parameterization: "v"
+ linear_start: 0.00085
+ linear_end: 0.012
+ num_timesteps_cond: 1
+ timesteps: 1000
+ first_stage_key: video
+ cond_stage_key: instruction
+ cond_stage_trainable: False
+ conditioning_key: hybrid
+ image_size: [40, 64]
+ channels: 4
+ scale_by_std: False
+ scale_factor: 0.18215
+ use_ema: False
+ uncond_type: 'empty_seq'
+ use_dynamic_rescale: true
+ base_scale: 0.7
+ fps_condition_type: 'fps'
+ perframe_ae: True
+ freeze_embedder: True
+ n_obs_steps_imagen: 2
+ n_obs_steps_acting: 2
+ agent_state_dim: 16
+ agent_action_dim: 16
+ decision_making_only: True
+
+ ###################### DP Related
+ input_pertub: 0.1
+ lr_scheduler: cosine
+ lr_warmup_steps: 2000
+ num_epochs: 30000
+ gradient_accumulate_every: 1
+ use_scheduler: True
+ dp_use_ema: True
+
+ dp_ema_config:
+ target: unifolm_wma.models.diffusion_head.ema_model.EMAModel
+ params:
+ update_after_step: 0
+ inv_gamma: 1.0
+ power: 0.75
+ min_value: 0.0
+ max_value: 0.9999
+
+ noise_scheduler_config:
+ target: diffusers.DDIMScheduler
+ params:
+ num_train_timesteps: 1000
+ beta_start: 0.0001
+ beta_end: 0.02
+ beta_schedule: squaredcos_cap_v2
+ clip_sample: True
+ set_alpha_to_one: True
+ steps_offset: 0
+ prediction_type: epsilon
+
+ dp_optimizer_config:
+ target: torch.optim.AdamW
+ params:
+ lr: 1.0e-4
+ betas: [0.95, 0.999]
+ eps: 1.0e-8
+ weight_decay: 1.0e-6
+
+ wma_config:
+ target: unifolm_wma.modules.networks.wma_model.WMAModel
+ params:
+ in_channels: 8
+ out_channels: 4
+ model_channels: 320
+ attention_resolutions:
+ - 4
+ - 2
+ - 1
+ num_res_blocks: 2
+ channel_mult:
+ - 1
+ - 2
+ - 4
+ - 4
+ dropout: 0.1
+ num_head_channels: 64
+ transformer_depth: 1
+ context_dim: 1024
+ use_linear: true
+ use_checkpoint: True
+ temporal_conv: True
+ temporal_attention: True
+ temporal_selfatt_only: True
+ use_relative_position: False
+ use_causal_attention: False
+ temporal_length: 16
+ addition_attention: True
+ image_cross_attention: True
+ default_fs: 10
+ fs_condition: True
+ cross_attention_scale_learnable: False
+ n_obs_steps: ${model.params.n_obs_steps_imagen}
+ num_stem_token: 16
+ base_model_gen_only: False
+
+ unet_head_config:
+ target: unifolm_wma.models.diffusion_head.conditional_unet1d.ConditionalUnet1D
+ params:
+ input_dim: ${model.params.agent_action_dim}
+ n_obs_steps: ${model.params.n_obs_steps_acting}
+ diffusion_step_embed_dim: 128
+ down_dims: [256, 512, 1024, 2048]
+ kernel_size: 5
+ n_groups: 8
+ cond_predict_scale: True
+ num_head_channels: ${model.params.wma_config.params.num_head_channels}
+ horizon: ${model.params.wma_config.params.temporal_length}
+ use_linear_attn: ${model.params.wma_config.params.use_linear}
+ use_linear_act_proj: True
+ act_proj_dim: 32
+ cond_cross_attention: False
+ context_dims: []
+ image_size: ${model.params.image_size}
+ imagen_cond_gradient: True
+ last_frame_only: False
+ use_imagen_mid_only: False
+ use_z_only: False
+
+ obs_encoder_config:
+ target: unifolm_wma.models.diffusion_head.vision.multi_image_obs_encoder.MultiImageObsEncoder
+ params:
+ rgb_model_config:
+ target: unifolm_wma.models.diffusion_head.vision.model_getter.get_resnet
+ params:
+ name: resnet18
+ weights: null
+ resize_shape: null
+ crop_shape: null
+ random_crop: False
+ use_group_norm: True
+ share_rgb_model: False
+ imagenet_norm: True
+ use_spatial_softmax: True
+ spatial_softmax_kp: 128
+
+ ###################### Action Tokenization
+ stem_process_config:
+ target: unifolm_wma.modules.encoders.condition.SATokenProjector
+ params:
+ dim: 1024
+ depth: 1
+ dim_head: 64
+ heads: 16
+ num_queries: ${model.params.wma_config.params.num_stem_token}
+ output_dim: 1024
+ ff_mult: 4
+ chunk_size: ${model.params.wma_config.params.temporal_length}
+
+ first_stage_config:
+ target: unifolm_wma.models.autoencoder.AutoencoderKL
+ params:
+ embed_dim: 4
+ monitor: val/rec_loss
+ ddconfig:
+ double_z: True
+ z_channels: 4
+ resolution: 256
+ in_channels: 3
+ out_ch: 3
+ ch: 128
+ ch_mult:
+ - 1
+ - 2
+ - 4
+ - 4
+ num_res_blocks: 2
+ attn_resolutions: []
+ dropout: 0.0
+ lossconfig:
+ target: torch.nn.Identity
+
+ cond_stage_config:
+ target: unifolm_wma.modules.encoders.condition.FrozenOpenCLIPEmbedder
+ params:
+ freeze: True
+ layer: "penultimate"
+
+ img_cond_stage_config:
+ target: unifolm_wma.modules.encoders.condition.FrozenOpenCLIPImageEmbedderV2
+ params:
+ freeze: true
+
+ image_proj_stage_config:
+ target: unifolm_wma.modules.encoders.resampler.Resampler
+ params:
+ dim: 1024
+ depth: 4
+ dim_head: 64
+ heads: 12
+ num_queries: 16
+ embedding_dim: 1280
+ output_dim: 1024
+ ff_mult: 4
+ video_length: ${model.params.wma_config.params.temporal_length}
+
+ normalization_config:
+ input_shapes:
+ observation.state: ${model.params.wma_config.params.action_unet_config.params.input_dim}
+ input_normalization_modes:
+ observation.state: 'min_max'
+ output_shapes:
+ action: ${model.params.wma_config.params.action_unet_config.params.input_dim}
+ output_normalization_modes:
+ action: 'min_max'
+
+data:
+ target: unifolm_wma.utils.data.DataModuleFromConfig
+ params:
+ batch_size: 6
+ num_workers: 12
+ wrap: False
+ test:
+ target: unifolm_wma.data.wma_data.WMAData
+ params:
+ data_dir: '/path/to/the/dataset/directory/that/contains/the/meta/folder/of/the/testing/case/under/a/transitions/folder' # e.g., /path/to/unifolm-world-model-action/examples/world_model_interaction_prompts
+ video_length: ${model.params.wma_config.params.temporal_length}
+ frame_stride: 2
+ load_raw_resolution: True
+ resolution: [320, 512]
+ spatial_transform: resize_center_crop
+ crop_resolution: [320, 512]
+ random_fs: False
+ cond_robot_label_prob: 0.0
+ normalization_mode: 'min_max'
+ individual_normalization: True
+ n_obs_steps: ${model.params.n_obs_steps_imagen}
+ max_action_dim: ${model.params.agent_action_dim}
+ max_state_dim: ${model.params.agent_state_dim}
+ dataset_and_weights:
+ unitree_g1_pack_camera: 1.0
diff --git a/scripts/evaluation/real_eval_server.py b/scripts/evaluation/real_eval_server.py
new file mode 100644
index 0000000..d780b5d
--- /dev/null
+++ b/scripts/evaluation/real_eval_server.py
@@ -0,0 +1,463 @@
+import argparse, os, sys
+import torch
+import torchvision
+import warnings
+import imageio
+import logging
+import matplotlib.pyplot as plt
+plt.switch_backend('agg')
+import traceback
+import uvicorn
+
+from omegaconf import OmegaConf
+from einops import rearrange, repeat
+from collections import OrderedDict
+from pytorch_lightning import seed_everything
+from torch import nn
+from fastapi import FastAPI
+from fastapi.responses import JSONResponse
+from typing import Any, Dict, Optional, Tuple, List
+from datetime import datetime
+
+from unifolm_wma.utils.utils import instantiate_from_config
+from unifolm_wma.models.samplers.ddim import DDIMSampler
+
+
+def get_device_from_parameters(module: nn.Module) -> torch.device:
+ """Get a module's device by checking one of its parameters.
+
+ Args:
+ module (nn.Module): PyTorch module.
+
+ Returns:
+ torch.device: The device where the module's parameters are stored.
+ """
+ return next(iter(module.parameters())).device
+
+
+def load_model_checkpoint(model: nn.Module, ckpt: str) -> nn.Module:
+ """Load model weights from checkpoint file.
+
+ Args:
+ model (nn.Module): Model to load weights into.
+ ckpt (str): Path to checkpoint file.
+
+ Returns:
+ nn.Module: Model with loaded weights.
+ """
+
+ state_dict = torch.load(ckpt, map_location="cpu")
+ if "state_dict" in list(state_dict.keys()):
+ state_dict = state_dict["state_dict"]
+ try:
+ model.load_state_dict(state_dict, strict=False)
+ except:
+ new_pl_sd = OrderedDict()
+ for k, v in state_dict.items():
+ new_pl_sd[k] = v
+
+ for k in list(new_pl_sd.keys()):
+ if "framestride_embed" in k:
+ new_key = k.replace("framestride_embed", "fps_embedding")
+ new_pl_sd[new_key] = new_pl_sd[k]
+ del new_pl_sd[k]
+ model.load_state_dict(new_pl_sd, strict=False)
+ else:
+ new_pl_sd = OrderedDict()
+ for key in state_dict['module'].keys():
+ new_pl_sd[key[16:]] = state_dict['module'][key]
+ model.load_state_dict(new_pl_sd)
+ print('>>> model checkpoint loaded.')
+ return model
+
+
+def write_video(video_path: str, stacked_frames: List[Any], fps: int) -> None:
+ """Write a video to disk using imageio.
+
+ Args:
+ video_path (str): Path to save the video.
+ stacked_frames (List[Any]): Frames to write.
+ fps (int): Frames per second.
+ """
+ with warnings.catch_warnings():
+ warnings.filterwarnings("ignore",
+ "pkg_resources is deprecated as an API",
+ category=DeprecationWarning)
+ imageio.mimsave(video_path, stacked_frames, fps=fps)
+
+
+def save_results(video: torch.Tensor, filename: str, fps: int = 8) -> None:
+ """Save a video tensor as an MP4 file.
+
+ Args:
+ video (torch.Tensor): Video tensor of shape (B, C, T, H, W).
+ filename (str): Path to save video.
+ fps (int, optional): Frame rate. Defaults to 8.
+
+ """
+ video = video.detach().cpu()
+ video = torch.clamp(video.float(), -1., 1.)
+ n = video.shape[0]
+ video = video.permute(2, 0, 1, 3, 4)
+
+ frame_grids = [
+ torchvision.utils.make_grid(framesheet, nrow=int(n), padding=0)
+ for framesheet in video
+ ]
+ grid = torch.stack(frame_grids, dim=0)
+ grid = (grid + 1.0) / 2.0
+ grid = (grid * 255).to(torch.uint8).permute(0, 2, 3, 1)
+ torchvision.io.write_video(filename,
+ grid,
+ fps=fps,
+ video_codec='h264',
+ options={'crf': '10'})
+
+
+def get_latent_z(model: nn.Module, videos: torch.Tensor) -> torch.Tensor:
+ """Encode videos into latent space.
+
+ Args:
+ model (nn.Module): Model with `encode_first_stage` method.
+ videos (torch.Tensor): Input videos (B, C, T, H, W).
+
+ Returns:
+ torch.Tensor: Latent representation (B, C, T, H, W).
+
+ """
+ b, c, t, h, w = videos.shape
+ x = rearrange(videos, 'b c t h w -> (b t) c h w')
+ z = model.encode_first_stage(x)
+ z = rearrange(z, '(b t) c h w -> b c t h w', b=b, t=t)
+ return z
+
+
+def image_guided_synthesis(
+ model: torch.nn.Module,
+ prompts: list[str],
+ observation: Dict[str, torch.Tensor],
+ noise_shape: tuple[int, int, int, int, int],
+ ddim_steps: int = 50,
+ ddim_eta: float = 1.0,
+ unconditional_guidance_scale: float = 1.0,
+ fs: int | None = None,
+ timestep_spacing: str = 'uniform',
+ guidance_rescale: float = 0.0,
+ **kwargs) -> Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ """Run inference with DDIM sampling.
+
+ Args:
+ model (nn.Module): Diffusion model.
+ prompts (Any): Conditioning text prompts.
+ observation (Dict[str, torch.Tensor]): Observation dictionary.
+ noise_shape (List[int]): Shape of noise tensor.
+ ddim_steps (int, optional): Number of DDIM steps. Defaults to 50.
+ ddim_eta (float, optional): Sampling eta. Defaults to 1.0.
+ unconditional_guidance_scale (float, optional): Guidance scale. Defaults to 1.0.
+ fs (Optional[int], optional): Frame stride or FPS. Defaults to None.
+ timestep_spacing (str, optional): Spacing strategy. Defaults to "uniform".
+ guidance_rescale (float, optional): Guidance rescale. Defaults to 0.0.
+ **kwargs (Any): Additional arguments.
+
+ Returns:
+ Tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
+ """
+
+ b, _, t, _, _ = noise_shape
+ ddim_sampler = DDIMSampler(model)
+ batch_size = noise_shape[0]
+ fs = torch.tensor([fs] * batch_size, dtype=torch.long, device=model.device)
+
+ img = observation['observation.images.top']
+ cond_img = img[:, -1, ...]
+ cond_img_emb = model.embedder(cond_img)
+ cond_img_emb = model.image_proj_model(cond_img_emb)
+
+ if model.model.conditioning_key == 'hybrid':
+ z = get_latent_z(model, img.permute(0, 2, 1, 3, 4))
+ img_cat_cond = z[:, :, -1:, :, :]
+ img_cat_cond = repeat(img_cat_cond,
+ 'b c t h w -> b c (repeat t) h w',
+ repeat=noise_shape[2])
+ cond = {"c_concat": [img_cat_cond]}
+
+ cond_ins_emb = model.get_learned_conditioning(prompts)
+ cond_state = model.state_projector(observation['observation.state'])
+ cond_state_emb = model.agent_state_pos_emb + cond_state
+
+ cond_action = model.action_projector(observation['action'])
+ cond_action_emb = model.agent_action_pos_emb + cond_action
+ cond_action_emb = torch.zeros_like(cond_action_emb)
+
+ cond["c_crossattn"] = [
+ torch.cat([cond_state_emb, cond_ins_emb, cond_img_emb], dim=1)
+ ]
+ cond["c_crossattn_action"] = [
+ observation['observation.images.top'].permute(
+ 0, 2, 1, 3, 4)[:, :, -model.n_obs_steps_acting:],
+ observation['observation.state'][:, -model.n_obs_steps_acting:]
+ ]
+
+ uc = None
+
+ kwargs.update({"unconditional_conditioning_img_nonetext": None})
+
+ cond_mask = None
+ cond_z0 = None
+
+ if ddim_sampler is not None:
+
+ samples, actions, states, intermedia = ddim_sampler.sample(
+ S=ddim_steps,
+ conditioning=cond,
+ batch_size=batch_size,
+ shape=noise_shape[1:],
+ verbose=False,
+ unconditional_guidance_scale=unconditional_guidance_scale,
+ unconditional_conditioning=uc,
+ eta=ddim_eta,
+ cfg_img=None,
+ mask=cond_mask,
+ x0=cond_z0,
+ fs=fs,
+ timestep_spacing=timestep_spacing,
+ guidance_rescale=guidance_rescale,
+ **kwargs)
+
+ # Reconstruct from latent to pixel space
+ batch_images = model.decode_first_stage(samples)
+ batch_variants = batch_images
+
+ return batch_variants, actions, states
+
+
+def run_inference(args: argparse.Namespace, gpu_num: int,
+ gpu_no: int) -> Tuple[nn.Module, List[int], Any]:
+ """
+ Run inference pipeline on prompts and image inputs.
+
+ Args:
+ args (argparse.Namespace): Parsed command-line arguments.
+ gpu_num (int): Number of GPUs.
+ gpu_no (int): Index of the current GPU.
+
+ Returns:
+ None
+ """
+ # Load config
+ config = OmegaConf.load(args.config)
+ # Set use_checkpoint as False as when using deepspeed, it encounters an error "deepspeed backend not set"
+ config['model']['params']['wma_config']['params']['use_checkpoint'] = False
+ model = instantiate_from_config(config.model)
+ model.perframe_ae = args.perframe_ae
+ assert os.path.exists(args.ckpt_path), "Error: checkpoint Not Found!"
+ model = load_model_checkpoint(model, args.ckpt_path)
+ model = model.cuda(gpu_no)
+ model.eval()
+ print(">>> Model is successfully loaded ...")
+
+ # Build unnomalizer
+ logging.info("***** Configing Data *****")
+ data = instantiate_from_config(config.data)
+ data.setup()
+ print(">>> Dataset is successfully loaded ...")
+
+ ## Run over data
+ assert (args.height % 16 == 0) and (
+ args.width % 16
+ == 0), "Error: image size [h,w] should be multiples of 16!"
+ assert args.bs == 1, "Current implementation only support [batch size = 1]!"
+
+ ## Get latent noise shape
+ h, w = args.height // 8, args.width // 8
+ channels = model.model.diffusion_model.out_channels
+ n_frames = args.video_length
+ print(f'>>> Generate {n_frames} frames under each generation ...')
+ noise_shape = [args.bs, channels, n_frames, h, w]
+
+ return model, noise_shape, data
+
+
+def get_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--savedir",
+ type=str,
+ default=None,
+ help="Path to save the results.")
+ parser.add_argument("--ckpt_path",
+ type=str,
+ default=None,
+ help="Path to the model checkpoint.")
+ parser.add_argument("--config", type=str, help="Path to the config file.")
+ parser.add_argument(
+ "--ddim_steps",
+ type=int,
+ default=50,
+ help="Number of DDIM steps. If non-positive, DDPM is used instead.")
+ parser.add_argument(
+ "--ddim_eta",
+ type=float,
+ default=1.0,
+ help="Eta for DDIM sampling. Set to 0.0 for deterministic results.")
+ parser.add_argument("--bs",
+ type=int,
+ default=1,
+ help="Batch size for inference. Must be 1.")
+ parser.add_argument("--height",
+ type=int,
+ default=320,
+ help="Height of the generated images in pixels.")
+ parser.add_argument("--width",
+ type=int,
+ default=512,
+ help="Width of the generated images in pixels.")
+ parser.add_argument(
+ "--frame_stride",
+ type=int,
+ default=3,
+ help=
+ "frame stride control for 256 model (larger->larger motion), FPS control for 512 or 1024 model (smaller->larger motion)"
+ )
+ parser.add_argument(
+ "--unconditional_guidance_scale",
+ type=float,
+ default=1.0,
+ help="Scale for classifier-free guidance during sampling.")
+ parser.add_argument("--seed",
+ type=int,
+ default=123,
+ help="Random seed for reproducibility.")
+ parser.add_argument("--video_length",
+ type=int,
+ default=16,
+ help="Number of frames in the generated video.")
+ parser.add_argument(
+ "--timestep_spacing",
+ type=str,
+ default="uniform",
+ help=
+ "Strategy for timestep scaling. See Table 2 in the paper: 'Common Diffusion Noise Schedules and Sample Steps are Flawed' (https://huggingface.co/papers/2305.08891)."
+ )
+ parser.add_argument(
+ "--guidance_rescale",
+ type=float,
+ default=0.0,
+ help=
+ "Rescale factor for guidance as discussed in 'Common Diffusion Noise Schedules and Sample Steps are Flawed' (https://huggingface.co/papers/2305.08891)."
+ )
+ parser.add_argument(
+ "--perframe_ae",
+ action='store_true',
+ default=False,
+ help=
+ "Use per-frame autoencoder decoding to reduce GPU memory usage. Recommended for models with resolutions like 576x1024."
+ )
+ return parser
+
+
+class Server:
+
+ def __init__(self, args: argparse.Namespace) -> None:
+ self.model_, self.noise_shape_, self.data_ = run_inference(args, 1, 0)
+ self.args_ = args
+ self.dataset_name = self.data_.dataset_configs['test']['params'][
+ 'dataset_name']
+ self.device_ = get_device_from_parameters(self.model_)
+
+ def normalize_image(self, image: torch.Tensor) -> torch.Tensor:
+ return (image / 255 - 0.5) * 2
+
+ def predict_action(self, payload: Dict[str, Any]) -> Any:
+ try:
+ images = payload['observation.images.top']
+ states = payload['observation.state']
+ actions = payload['action'] # Should be all zeros
+ language_instruction = payload['language_instruction']
+
+ images = torch.tensor(images).cuda()
+ images = self.data_.test_datasets[
+ self.dataset_name].spatial_transform(images).unsqueeze(0)
+ images = self.normalize_image(images)
+ print(f"images shape: {images.shape} ...")
+ states = torch.tensor(states)
+ states = self.data_.test_datasets[self.dataset_name].normalizer(
+ {'observation.state': states})['observation.state']
+ states, _ = self.data_.test_datasets[
+ self.dataset_name]._map_to_uni_state(states, "joint position")
+ print(f"states shape: {states.shape} ...")
+ actions = torch.tensor(actions)
+ actions, action_mask = self.data_.test_datasets[
+ self.dataset_name]._map_to_uni_action(actions,
+ "joint position")
+ print(f"actions shape: {actions.shape} ...")
+ print("=" * 20)
+ states = states.unsqueeze(0).cuda()
+ actions = actions.unsqueeze(0).cuda()
+
+ observation = {
+ 'observation.images.top': images,
+ 'observation.state': states,
+ 'action': actions
+ }
+ observation = {
+ key: observation[key].to(self.device_, non_blocking=True)
+ for key in observation
+ }
+
+ args = self.args_
+ pred_videos, pred_action, _ = image_guided_synthesis(
+ self.model_,
+ language_instruction,
+ observation,
+ self.noise_shape_,
+ ddim_steps=args.ddim_steps,
+ ddim_ets=args.ddim_eta,
+ unconditional_guidance_scale=args.unconditional_guidance_scale,
+ fs=30 / args.frame_stride,
+ timestep_spacing=args.timestep_spacing,
+ guidance_rescale=args.guidance_rescale)
+
+ pred_action = pred_action[..., action_mask[0] == 1.0][0].cpu()
+ pred_action = self.data_.test_datasets[
+ self.dataset_name].unnormalizer({'action':
+ pred_action})['action']
+
+ os.makedirs(args.savedir, exist_ok=True)
+ current_time = datetime.now().strftime("%H:%M:%S")
+ video_file = f'{args.savedir}/{current_time}.mp4'
+ save_results(pred_videos.cpu(), video_file)
+
+ response = {
+ 'result': 'ok',
+ 'action': pred_action.tolist(),
+ 'desc': 'success'
+ }
+ return JSONResponse(response)
+
+ except:
+ logging.error(traceback.format_exc())
+ logging.warning(
+ "Your request threw an error; make sure your request complies with the expected format:\n"
+ "{'image': np.ndarray, 'instruction': str}\n"
+ "You can optionally an `unnorm_key: str` to specific the dataset statistics you want to use for "
+ "de-normalizing the output actions.")
+ return {'result': 'error', 'desc': traceback.format_exc()}
+
+ def run(self, host: str = "127.0.0.1", port: int = 8000) -> None:
+ self.app = FastAPI()
+ self.app.post("/predict_action")(self.predict_action)
+ print(">>> Inference server is ready ... ")
+ uvicorn.run(self.app, host=host, port=port)
+ print(">>> Inference server stops ... ")
+ return
+
+
+if __name__ == '__main__':
+ parser = get_parser()
+ args = parser.parse_args()
+ seed = args.seed
+ seed_everything(seed)
+ rank, gpu_num = 0, 1
+ print(">>> Launch inference server ... ")
+ server = Server(args)
+ server.run()
diff --git a/scripts/run_real_eval_server.sh b/scripts/run_real_eval_server.sh
new file mode 100644
index 0000000..f3c1947
--- /dev/null
+++ b/scripts/run_real_eval_server.sh
@@ -0,0 +1,26 @@
+model_name=testing
+ckpt=/path/to/model/checkpoint
+config=configs/inference/world_model_decision_making.yaml
+seed=123
+res_dir="path/to/results/directory"
+datasets=(
+ "unitree_g1_pack_camera"
+)
+
+
+for dataset in "${datasets[@]}"; do
+ CUDA_VISIBLE_DEVICES=0 python3 scripts/evaluation/real_eval_server.py \
+ --seed ${seed} \
+ --ckpt_path $ckpt \
+ --config $config \
+ --savedir "${res_dir}/${dataset}/${model_name}/videos" \
+ --bs 1 --height 320 --width 512 \
+ --unconditional_guidance_scale 1.0 \
+ --ddim_steps 16 \
+ --ddim_eta 1.0 \
+ --video_length 16 \
+ --frame_stride 2 \
+ --timestep_spacing 'uniform_trailing' \
+ --guidance_rescale 0.7 \
+ --perframe_ae
+done
diff --git a/unitree_deploy/README.md b/unitree_deploy/README.md
new file mode 100644
index 0000000..c328f86
--- /dev/null
+++ b/unitree_deploy/README.md
@@ -0,0 +1,207 @@
+# Unitree Deploy
+
+This document provides instructions for setting up the deployment environment for Unitree G1 (with gripper) and Z1 platforms, including dependency installation, image service startup, and gripper control.
+
+# 0. π Introduction
+
+This repository is used for model deployment with Unitree robots.
+
+---
+
+# 1. π οΈ Environment Setup
+
+```bash
+conda create -n unitree_deploy python=3.10 && conda activate unitree_deploy
+
+conda install pinocchio -c conda-forge
+pip install -e .
+
+# Optional: Install lerobot dependencies
+pip install -e ".[lerobot]"
+
+git clone https://github.com/unitreerobotics/unitree_sdk2_python.git
+cd unitree_sdk2_python && pip install -e . && cd ..
+```
+
+---
+# 2. π Start
+
+**Tip: Keep all devices on the same LAN**
+
+## 2.1 π€ Run G1 with Dex_1 Gripper
+
+### 2.1.1 π· Image Capture Service Setup (G1 Board)
+
+[To open the image_server, follow these steps](https://github.com/unitreerobotics/xr_teleoperate?tab=readme-ov-file#31-%EF%B8%8F-image-service)
+1. Connect to the G1 board:
+ ```bash
+ ssh unitree@192.168.123.164 # Password: 123
+ ```
+
+2. Activate the environment and start the image server:
+ ```bash
+ conda activate tv
+ cd ~/image_server
+ python image_server.py
+ ```
+
+---
+
+### 2.1.2 π€ Dex_1 Gripper Service Setup (Development PC2)
+
+Refer to the [Dex_1 Gripper Installation Guide](https://github.com/unitreerobotics/dex1_1_service?tab=readme-ov-file#1--installation) for detailed setup instructions.
+
+1. Navigate to the service directory:
+ ```bash
+ cd ~/dex1_1_service/build
+ ```
+
+2. Start the gripper service, **ifconfig examines its own dds networkInterface**:
+ ```bash
+ sudo ./dex1_1_gripper_server --network eth0 -l -r
+ ```
+
+3. Verify communication with the gripper service:
+ ```bash
+ ./test_dex1_1_gripper_server --network eth0 -l -r
+ ```
+
+---
+
+### 2.1.2 β
Testing
+
+Perform the following tests to ensure proper functionality:
+
+- **Dex1 Gripper Test**:
+ ```bash
+ python test/endeffector/test_dex1.py
+ ```
+
+- **G1 Arm Test**:
+ ```bash
+ python test/arm/g1/test_g1_arm.py
+ ```
+
+- **Image Client Camera Test**:
+ ```bash
+ python test/camera/test_image_client_camera.py
+ ```
+
+- **G1 Datasets Replay**:
+ ```bash
+ python test/test_replay.py --repo-id unitreerobotics/G1_CameraPackaging_NewDataset --robot_type g1_dex1
+ ```
+---
+
+## 2.2 π¦Ώ Run Z1
+
+### 2.2.1 π¦Ώ Z1 Setup
+Clone and build the required repositories:
+
+1. Download [z1_controller](https://github.com/unitreerobotics/z1_controller.git) and [z1_sdk](https://github.com/unitreerobotics/z1_sdk.git).
+
+2. Build the repositories:
+ ```bash
+ mkdir build && cd build
+ cmake .. && make -j
+ ```
+
+3. Copy the `unitree_arm_interface` library: [Modify according to your own path]
+ ```bash
+ cp z1_sdk/lib/unitree_arm_interface.cpython-310-x86_64-linux-gnu.so ./unitree_deploy/robot_devices/arm
+ ```
+
+4. Start the Z1 controller [Modify according to your own path]:
+ ```bash
+ cd z1_controller/build
+ ./z1_ctrl
+ ```
+
+---
+
+### 2.2.2 Testing β
+
+Run the following tests:
+
+- **Realsense Camera Test**:
+ ```bash
+ python test/camera/test_realsense_camera.py # Modify the corresponding serial number according to your realsense
+ ```
+
+- **Z1 Arm Test**:
+ ```bash
+ python test/arm/z1/test_z1_arm.py
+ ```
+
+- **Z1 Environment Test**:
+ ```bash
+ python test/arm/z1/test_z1_env.py
+ ```
+
+- **Z1 Datasets Replay**:
+ ```bash
+ python test/test_replay.py --repo-id unitreerobotics/Z1_StackBox_Dataset --robot_type z1_realsense
+ ```
+---
+
+## 2.3 π¦Ώ Run Z1_Dual
+
+### 2.3.1 π¦Ώ Z1 Setup and Dex1 Setup
+Clone and build the required repositories:
+
+1. Download and compile the corresponding code according to the above z1 steps and Download the gripper program to start locally
+
+2. [Modify the multi-machine control according to the document](https://support.unitree.com/home/zh/Z1_developer/sdk_operation)
+
+3. [Download the modified z1_sdk_1 and then compile it](https://github.com/unitreerobotics/z1_sdk/tree/z1_dual), Copy the `unitree_arm_interface` library: [Modify according to your own path]
+ ```bash
+ cp z1_sdk/lib/unitree_arm_interface.cpython-310-x86_64-linux-gnu.so ./unitree_deploy/robot_devices/arm
+ ```
+
+4. Start the Z1 controller [Modify according to your own path]:
+ ```bash
+ cd z1_controller/builb && ./z1_ctrl
+ cd z1_controller_1/builb && ./z1_ctrl
+ ```
+5. Start the gripper service, **ifconfig examines its own dds networkInterface**:
+ ```
+ sudo ./dex1_1_gripper_server --network eth0 -l -r
+ ```
+---
+
+### 2.3.2 Testing β
+
+Run the following tests:
+
+- **Z1_Dual Arm Test**:
+ ```bash
+ python test/arm/z1/test_z1_arm_dual.py
+ ```
+
+- **Z1_Dual Datasets Replay**:
+ ```bash
+ python test/test_replay.py --repo-id unitreerobotics/Z1_Dual_Dex1_StackBox_Dataset_V2 --robot_type z1_dual_dex1_realsense
+ ```
+---
+
+
+# 3.π§ Inference and Deploy
+1. [Modify the corresponding parameters according to your configuration](./unitree_deploy/robot/robot_configs.py)
+2. Go back the **step-2 of Client Setup** under the [Inference and Deployment under Decision-Making Mode](https://github.com/unitreerobotics/unitree-world-model/blob/main/README.md).
+
+# 4.ποΈ Code structure
+
+[If you want to add your own robot equipment, you can build it according to this document](./docs/GettingStarted.md)
+
+
+# 5. π€ Troubleshooting
+
+For assistance, contact the project maintainer or refer to the respective GitHub repository documentation. π
+
+
+# 6. π Acknowledgement
+
+This code builds upon following open-source code-bases. Please visit the URLs to see the respective LICENSES (If you find these projects valuable, it would be greatly appreciated if you could give them a star rating.):
+
+1. https://github.com/huggingface/lerobot
+2. https://github.com/unitreerobotics/unitree_sdk2_python
\ No newline at end of file
diff --git a/unitree_deploy/docs/GettingStarted.md b/unitree_deploy/docs/GettingStarted.md
new file mode 100644
index 0000000..dadb7b2
--- /dev/null
+++ b/unitree_deploy/docs/GettingStarted.md
@@ -0,0 +1,70 @@
+# Getting Started
+
+### Code framework
+
+
+| Module Name | Documentation Link |
+| ------------------------- | -------------------------------------------------- |
+| robots | [build_robot](./build_robot.md) |
+| robot_devices/arm | [add_robot_arm](./add_robot_arm.md) |
+| robot_devices/cameras | [add_robot_camera](./add_robot_camera.md) |
+| robot_devices/endeffector | [add_robot_endeffector](./add_robot_endeffector.md)|
+
+### Simple Usage (Example code, not executable)
+
+```python
+import time
+import math
+import torch
+
+from unitree_deploy.robot.robot_utils import make_robot
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+
+class YourPolicy:
+ def predict_action(self, observation, policy):
+ # Logic for predicting action
+ pass
+
+class UnitreeEnv:
+ def __init__(self):
+ self.robot = make_robot(self.robot_type)
+ if not self.robot.is_connected:
+ self.robot.connect()
+ # If disconnection is needed, call disconnect() here
+ # self.robot.disconnect()
+
+ def get_obs(self):
+ # Get observation
+ observation = self.robot.capture_observation()
+ return observation
+
+ def step(self, pred_action, t_command_target):
+ # Execute action
+ t_cycle_end = time.monotonic() + self.control_dt
+ t_command_target = t_cycle_end + self.control_dt
+ action = self.robot.send_action(torch.from_numpy(pred_action), t_command_target)
+ precise_wait(t_cycle_end)
+ return action
+
+if __name__ == "__main__":
+ policy = YourPolicy() # Create policy instance
+ env = UnitreeEnv() # Create environment instance
+
+ t_start = time.monotonic() # Get start time
+ iter_idx = 0 # Initialize iteration index
+ control_dt = 1 / 30 # Control loop interval (30Hz)
+
+ try:
+ while True:
+ t_cycle_end = t_start + (iter_idx + 1) * control_dt # Calculate end time of current cycle
+ t_command_target = t_cycle_end + control_dt # Calculate command target time
+
+ observation = env.get_obs() # Get environment observation
+ pred_action = policy.predict_action(observation, policy) # Predict action
+ env.step(pred_action, t_command_target) # Execute action
+
+ precise_wait(t_cycle_end) # Wait until cycle end
+ iter_idx += 1 # Update iteration index
+ finally:
+ # Perform cleanup operations on exit (e.g., disconnect robot)
+ pass
diff --git a/unitree_deploy/docs/add_robot_arm.md b/unitree_deploy/docs/add_robot_arm.md
new file mode 100644
index 0000000..900ab64
--- /dev/null
+++ b/unitree_deploy/docs/add_robot_arm.md
@@ -0,0 +1,76 @@
+# How to Build Your Own Arm
+
+### Define your own config for the robot arm (unitree_deploy/robot_devices/arm/config.py)
+
+```python
+@ArmConfig.register_subclass("z1") # Register your custom arm wrapper. Here use def __init__(self, config: Z1DualArmConfig):
+@dataclass
+class Z1ArmConfig(ArmConfig):
+ port: str
+ motors: dict[str, tuple[int, str]]
+ mock: bool = False
+ init_pose_left: list = None
+ init_pose_right: list = None
+ control_dt: float = 1/500.0
+
+# Default parameters go first [parameters that may need to be customized],
+# Non-default parameters go later [fixed parameters]
+```
+
+### Description of methods in your arm class (unitree_deploy/robot_devices/arm/utils.py)
+
+```python
+# Base class for Arm, extensible with required methods
+
+class Arm(Protocol):
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+
+ def read_current_motor_q(self): ...
+ def read_current_arm_q(self): ...
+ def read_current_arm_dq(self): ...
+ def write_arm(self): ...
+
+ def arm_ik(self): ...
+```
+
+How to implement external calls?
+Use make_arm_motors_buses_from_configs [based on the config file] to construct the UnitreeRobot class.
+Use make_arm_motors_bus [based on arm_type] which is generally used for external module loading.
+
+### Implementation of the arm class (unitree_deploy/robot_devices/arm/.../....py)
+
+```python
+ # These methods need to be implemented and completed
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+ # connect() and disconnect() should handle initialization and homing respectively
+
+ def read_current_motor_q(self): ...
+ def read_current_arm_q(self): ...
+ def read_current_arm_dq(self): ...
+ # Outputs should be unified as np.ndarray
+
+ def write_arm(self): ...
+ # Write control commands here
+
+ def arm_ik(self): ...
+ # Wrap IK into your own arm class for external calling
+
+ # Private/protected properties [for reading motor names, IDs, etc.]
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+```
+
+All arms use threading to implement \_subscribe_motor_state and \_ctrl_motor_state threads for internal reading and writing within the class.
diff --git a/unitree_deploy/docs/add_robot_camera.md b/unitree_deploy/docs/add_robot_camera.md
new file mode 100644
index 0000000..d006c73
--- /dev/null
+++ b/unitree_deploy/docs/add_robot_camera.md
@@ -0,0 +1,66 @@
+# How to build your own cameras
+
+### Define your own config for cameras (unitree_deploy/robot_devices/cameras/config.py)
+
+```python
+@CameraConfig.register_subclass("opencv") # Define and wrap your own cameras. Here use def __init__(self, config: OpenCVCameraConfig):
+@dataclass
+class OpenCVCameraConfig(CameraConfig):
+ """
+ Example of tested options for Intel Real Sense D405:
+
+ OpenCVCameraConfig(0, 30, 640, 480)
+ OpenCVCameraConfig(0, 60, 640, 480)
+ OpenCVCameraConfig(0, 90, 640, 480)
+ OpenCVCameraConfig(0, 30, 1280, 720)
+
+ """
+ # Define the required camera parameters
+ camera_index: int
+ fps: int | None = None
+ width: int | None = None
+ height: int | None = None
+ color_mode: str = "rgb"
+ channels: int | None = None
+ rotation: int | None = None
+ mock: bool = False
+
+ def __post_init__(self):
+ if self.color_mode not in ["rgb", "bgr"]:
+ raise ValueError(
+ f"`color_mode` is expected to be 'rgb' or 'bgr', but {self.color_mode} is provided."
+ )
+
+ self.channels = 3
+
+ if self.rotation not in [-90, None, 90, 180]:
+ raise ValueError(f"`rotation` must be in [-90, None, 90, 180] (got {self.rotation})")
+
+# Default parameters go first [parameters that need to be customized],
+# Non-default parameters go later [fixed parameters]
+```
+
+### Description of methods in your cameras class (unitree_deploy/robot_devices/cameras/utils.py)
+
+```python
+# Base class for cameras, extensible with required methods
+
+class Camera(Protocol):
+ def connect(self): ...
+ def read(self, temporary_color: str | None = None) -> np.ndarray: ... # Single-threaded reading
+ def async_read(self) -> np.ndarray: ... # Multi-threaded
+ def disconnect(self): ...
+```
+
+How can external modules implement calls? Use **make_cameras_from_configs [based on configuration files]** to construct the `UnitreeRobot` class.
+**make_camera [based on camera_type]** is generally used for external module loading.
+
+### Implementation of the `camera` class (unitree_deploy/robot_devices/camera/.../....py)
+
+```python
+ # These need to be completed, focusing on implementing these two parts
+ def read(self, temporary_color: str | None = None) -> np.ndarray: ... # Single-threaded reading
+ def async_read(self) -> np.ndarray: ... # Multi-threaded
+```
+
+All cameras use threading to implement `async_read` for internal read and write operations.
diff --git a/unitree_deploy/docs/add_robot_endeffector.md b/unitree_deploy/docs/add_robot_endeffector.md
new file mode 100644
index 0000000..9680d26
--- /dev/null
+++ b/unitree_deploy/docs/add_robot_endeffector.md
@@ -0,0 +1,77 @@
+# How to Build Your Own End-Effector [Currently dex_1 and dex_3 are available]
+
+### Define your own config for the end-effector (unitree_deploy/robot_devices/endeffector/config.py)
+
+```python
+@EndEffectorConfig.register_subclass("gripper") # Register your custom end-effector wrapper. Here it uses def __init__(self, config: GripperConfig):
+@dataclass
+class GripperConfig(EndEffectorConfig):
+ motors: dict[str, tuple[int, str]]
+ unit_test: bool = False
+ control_dt: float = 1/200
+ mock: bool = False
+
+ def __post_init__(self):
+ if self.control_dt < 0.002:
+ raise ValueError(f"`control_dt` must > 1/500 (got {self.control_dt})")
+
+# Default arguments should be placed first [parameters that may need to be customized],
+# Non-default arguments should be placed later [fixed or less important parameters].
+```
+
+### Description of methods in your end-effector class (unitree_deploy/robot_devices/endeffector/utils.py)
+
+```python
+# Base class for EndEffector, extend with required methods
+
+class EndEffector(Protocol):
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+
+ def read_current_endeffector_q(self): ...
+ def read_current_endeffector_dq(self): ...
+ def write_endeffector(self): ...
+
+ def endeffector_ik(self): ...
+```
+
+How to call externally?
+Use make_endeffector_motors_buses_from_configs β Construct the UnitreeRobot class based on the config file
+Use make_endeffector_motors_bus β Construct based on endeffector_type (typically for external module loading)
+
+### Implementation of your end-effector class (unitree_deploy/robot_devices/endeffector/.../....py)
+
+```python
+ # These methods need to be implemented and completed
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+ # connect() and disconnect() should handle initialization and homing respectively
+
+ def read_current_endeffector_q(self): ...
+ def read_current_endeffector_dq(self): ...
+ # Outputs should be unified as np.ndarray
+
+ def write_endeffector(self): ...
+ # Write control commands here
+
+ def arm_ik(self): ...
+ # Wrap IK into your own arm class, to be called externally
+
+ # Private/protected properties
+ # (for reading motor names, IDs, etc. These will be used in UnitreeRobot class for dataset encapsulation)
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+```
+
+For arms, use threading to implement \_subscribe_gripper_motor_state (thread for reading motor states),\_ctrl_gripper_motor (thread for motor control),Both threads should run internally within the class.
diff --git a/unitree_deploy/docs/build_robot.md b/unitree_deploy/docs/build_robot.md
new file mode 100644
index 0000000..b90b9a7
--- /dev/null
+++ b/unitree_deploy/docs/build_robot.md
@@ -0,0 +1,140 @@
+# Build your own robot
+
+### Add your own config ((unitree_deploy/robot/robot_configs.py))
+
+The base class of robot config is defined as **UnitreeRobotConfig**
+
+```python
+class UnitreeRobotConfig(RobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=lambda: {}) # Corresponding to your own camera
+ arm: dict[str, ArmConfig] = field(default_factory=lambda: {}) # Corresponding to your own arm
+ endeffector: dict[str, EndEffectorConfig] = field(default_factory=lambda: {}) # Corresponding to your own end-effector
+
+ mock: bool = False # Simulation [To be implemented, for debugging, to check some class definitions and message type formats]
+```
+
+Specific example: separately fill in \[name\]:robot_devies β cameras,
+arm, endeffector.\
+If not provided, they default to empty and will not affect the system.\
+(In principle, different robot_devies and different quantities can be
+constructed.)
+
+```python
+class Z1dual_Dex1_Opencv_RobotConfig(UnitreeRobotConfig):
+
+ # Troubleshooting: If one of your IntelRealSense cameras freezes during
+ # data recording due to bandwidth limit, you might need to plug the camera
+ # into another USB hub or PCIe card.
+ cameras: dict[str, CameraConfig] = field(
+ default_factory=lambda: { # Add corresponding configs for different cameras [name]:OpenCVCameraConfig + required parameters
+ "cam_high": OpenCVCameraConfig(
+ camera_index="/dev/video0",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_left_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video2",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_right_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video4",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ }
+ )
+
+ arm: dict[str, ArmConfig] = field(
+ default_factory=lambda: {
+ "z1_dual": Z1DualArmConfig( # Add corresponding configs for different arms [name]:Z1DualArmConfig + required parameters
+ unit_test = False,
+ init_pose_left = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ init_pose_right = [0.0, 0.0, 0.0, 0.0, 0.0, 0.0],
+ control_dt = 1/500.0,
+ motors={
+ # name: (index, model)
+ "kLeftWaist": [0, "z1-joint"],
+ "kLeftShoulder": [1, "z1-joint"],
+ "kLeftElbow": [2, "z1-joint"],
+ "kLeftForearmRoll": [3, "z1-joint"],
+ "kLeftWristAngle": [4, "z1-joint"],
+ "kLeftWristRotate": [5, "z1-joint"],
+
+ "kRightWaist": [7, "z1-joint"],
+ "kRightShoulder": [8, "z1-joint"],
+ "kRightElbow": [9, "z1-joint"],
+ "kRightForearmRoll": [10, "z1-joint"],
+ "kRightWristAngle": [11, "z1-joint"],
+ "kRightWristRotate": [12, "z1-joint"],
+ },
+ ),
+ }
+ )
+
+ endeffector: dict[str, EndEffectorConfig] = field(
+ default_factory=lambda: {
+ "gripper": GripperConfig( # Add corresponding configs for different end-effectors [name]:GripperConfig + required parameters
+ unit_test = False,
+ unit_test = True,
+ control_dt = 1/250,
+ motors={
+ # name: (index, model)
+ "kLeftGripper": [0, "z1_gripper-joint"],
+ "kRightGripper": [1, "z1_gripper-joint"],
+ },
+ ),
+ }
+ )
+
+ mock: bool = False
+```
+
+---
+
+### robot utils ((unitree_deploy/robot/utils.py))
+
+```python
+Implementation of the Robot base class
+
+class Robot(Protocol):
+ robot_type: str
+ features: dict
+
+ def connect(self): ... # Connect devices (including cameras, arms, end-effectors of robot_devies)
+ def capture_observation(self): ... # capture_observation (Get current state, including data from camera + arm + end-effector)
+ def send_action(self, action): ... # send_action (Send action to arm + end-effector actuators, can be used for model inference and data replay)
+ def disconnect(self): ... # Disconnect devices
+```
+
+External calls **make_robot_from_config** and **make_robot** are used in
+**control_robot**, to initialize the robot and implement specific
+functions.
+
+---
+
+### manipulator ((unitree_deploy/robot/manipulator.py))
+
+UnitreeRobot implements initialization by calling
+**UnitreeRobotConfig**.
+
+```python
+ Several important parts of the implementation
+
+ def capture_observation(self): # Get current obs, return { observation.state, observation.images}
+
+ def send_action( # Model inference and data replay, receives action + time
+ self, action: torch.Tensor, t_command_target:float|None = None
+ ) -> torch.Tensor:
+
+ # Here we input device data
+ # Output (arm + end-effector) joint angle positions, end-effector positions, or other data conversion (IK is implemented here!)
+ # Output is uniformly converted into joint angle positions {"left":arm_joint_points, "roght":arm_joint_points} + {"left":endeffector_joint_points, "roght":endeffector_joint_points}
+ # Why consider left and right? Because this separates single-arm cases, and different arms and different end-effectors.
+ # This way, the implementation can work properly.
+ def convert_data_based_on_robot_type(self, robot_type: str, leader_pos: dict[str, np.ndarray]
+ ) -> None | tuple[dict[str, np.ndarray], dict[str, np.ndarray]]:
+```
diff --git a/unitree_deploy/pyproject.toml b/unitree_deploy/pyproject.toml
new file mode 100644
index 0000000..e0aabb3
--- /dev/null
+++ b/unitree_deploy/pyproject.toml
@@ -0,0 +1,51 @@
+[build-system]
+requires = ["setuptools", "wheel"]
+build-backend = "setuptools.build_meta"
+
+[project]
+name = "unitree_deploy"
+version = "0.0.3"
+description = "unitree deploy"
+readme = "README.md"
+requires-python = ">=3.10"
+license = { text = "BSD-3-Clause" }
+authors = [
+ { name = "hengguo", email = "rd_gh@unitree.com" }
+]
+keywords = ["unitree", "robotics", "deployment"]
+
+dependencies = [
+ "tyro",
+ "draccus",
+ "datasets==3.3.0",
+ "meshcat",
+ "pyrealsense2",
+ "numpy",
+ "opencv-python",
+ "mujoco",
+ "matplotlib",
+ "dm_env",
+ "torch>=2.2.1,<2.8.0",
+ "rerun-sdk>=0.21.0,<0.23.0",
+]
+[tool.setuptools]
+packages = ["unitree_deploy"]
+
+[project.optional-dependencies]
+lerobot = [
+ "lerobot @ git+https://github.com/huggingface/lerobot.git@0878c68"
+]
+
+[tool.ruff]
+line-length = 110
+target-version = "py310"
+exclude = ["build", "venv", "__pycache__"]
+fix = true
+show-fixes = true
+
+[tool.ruff.lint]
+select = ["E4", "E7", "E9", "F", "I", "N", "B", "C4", "SIM"]
+ignore = ["N801"]
+
+[tool.ruff.lint.per-file-ignores]
+"arm_indexs.py" = ["N815"]
\ No newline at end of file
diff --git a/unitree_deploy/scripts/robot_client.py b/unitree_deploy/scripts/robot_client.py
new file mode 100644
index 0000000..da98126
--- /dev/null
+++ b/unitree_deploy/scripts/robot_client.py
@@ -0,0 +1,198 @@
+import argparse
+import os
+import time
+import cv2
+import numpy as np
+import torch
+import tqdm
+
+from typing import Any, Deque, MutableMapping, OrderedDict
+from collections import deque
+from pathlib import Path
+
+from unitree_deploy.real_unitree_env import make_real_env
+from unitree_deploy.utils.eval_utils import (
+ ACTTemporalEnsembler,
+ LongConnectionClient,
+ populate_queues,
+)
+
+# -----------------------------------------------------------------------------
+# Network & environment defaults
+# -----------------------------------------------------------------------------
+os.environ["http_proxy"] = ""
+os.environ["https_proxy"] = ""
+HOST = "127.0.0.1"
+PORT = 8000
+BASE_URL = f"http://{HOST}:{PORT}"
+
+# fmt: off
+INIT_POSE = {
+ 'g1_dex1': np.array([0.10559805, 0.02726714, -0.01210221, -0.33341318, -0.22513399, -0.02627627, -0.15437093, 0.1273793 , -0.1674708 , -0.11544029, -0.40095493, 0.44332668, 0.11566751, 0.3936641, 5.4, 5.4], dtype=np.float32),
+ 'z1_dual_dex1_realsense': np.array([-1.0262332, 1.4281361, -1.2149128, 0.6473399, -0.12425245, 0.44945636, 0.89584476, 1.2593982, -1.0737865, 0.6672816, 0.39730102, -0.47400007, 0.9894176, 0.9817477 ], dtype=np.float32),
+ 'z1_realsense': np.array([-0.06940782, 1.4751548, -0.7554075, 1.0501366, 0.02931615, -0.02810347, -0.99238837], dtype=np.float32),
+}
+ZERO_ACTION = {
+ 'g1_dex1': torch.zeros(16, dtype=torch.float32),
+ 'z1_dual_dex1_realsense': torch.zeros(14, dtype=torch.float32),
+ 'z1_realsense': torch.zeros(7, dtype=torch.float32),
+}
+CAM_KEY = {
+ 'g1_dex1': 'cam_right_high',
+ 'z1_dual_dex1_realsense': 'cam_high',
+ 'z1_realsense': 'cam_high',
+}
+# fmt: on
+
+
+def prepare_observation(args: argparse.Namespace, obs: Any) -> OrderedDict:
+ """
+ Convert a raw env observation into the model's expected input dict.
+ """
+ rgb_image = cv2.cvtColor(
+ obs.observation["images"][CAM_KEY[args.robot_type]], cv2.COLOR_BGR2RGB)
+ observation = {
+ "observation.images.top":
+ torch.from_numpy(rgb_image).permute(2, 0, 1),
+ "observation.state":
+ torch.from_numpy(obs.observation["qpos"]),
+ "action": ZERO_ACTION[args.robot_type],
+ }
+ return OrderedDict(observation)
+
+
+def run_policy(
+ args: argparse.Namespace,
+ env: Any,
+ client: LongConnectionClient,
+ temporal_ensembler: ACTTemporalEnsembler,
+ cond_obs_queues: MutableMapping[str, Deque[torch.Tensor]],
+ output_dir: Path,
+) -> None:
+ """
+ Single rollout loop:
+ 1) warm start the robot,
+ 2) stream observations,
+ 3) fetch actions from the policy server,
+ 4) execute with temporal ensembling for smoother control.
+ """
+
+ _ = env.step(INIT_POSE[args.robot_type])
+ time.sleep(2.0)
+ t = 0
+
+ while True:
+ # Gapture observation
+ obs = env.get_observation(t)
+ # Format observation
+ obs = prepare_observation(args, obs)
+ cond_obs_queues = populate_queues(cond_obs_queues, obs)
+ # Call server to get actions
+ pred_actions = client.predict_action(args.language_instruction,
+ cond_obs_queues).unsqueeze(0)
+ # Keep only the next horizon of actions and apply temporal ensemble smoothing
+ actions = temporal_ensembler.update(
+ pred_actions[:, :args.action_horizon])[0]
+
+ # Execute the actions
+ for n in range(args.exe_steps):
+ action = actions[n].cpu().numpy()
+ print(f">>> Exec => step {n} action: {action}", flush=True)
+ print("---------------------------------------------")
+
+ # Maintain real-time loop at `control_freq` Hz
+ t1 = time.time()
+ obs = env.step(action)
+ time.sleep(max(0, 1 / args.control_freq - time.time() + t1))
+ t += 1
+
+ # Prime the queue for the next action step (except after the last one in this chunk)
+ if n < args.exe_steps - 1:
+ obs = prepare_observation(args, obs)
+ cond_obs_queues = populate_queues(cond_obs_queues, obs)
+
+
+def run_eval(args: argparse.Namespace) -> None:
+ client = LongConnectionClient(BASE_URL)
+
+ # Initialize ACT temporal moving-averge smoother
+ temporal_ensembler = ACTTemporalEnsembler(temporal_ensemble_coeff=0.01,
+ chunk_size=args.action_horizon,
+ exe_steps=args.exe_steps)
+ temporal_ensembler.reset()
+
+ # Initialize observation and action horizon queue
+ cond_obs_queues = {
+ "observation.images.top": deque(maxlen=args.observation_horizon),
+ "observation.state": deque(maxlen=args.observation_horizon),
+ "action": deque(
+ maxlen=16), # NOTE: HAND CODE AS THE MODEL PREDCIT FUTURE 16 STEPS
+ }
+
+ env = make_real_env(
+ robot_type=args.robot_type,
+ dt=1 / args.control_freq,
+ )
+ env.connect()
+
+ try:
+ for episode_idx in tqdm.tqdm(range(0, args.num_rollouts_planned)):
+ output_dir = Path(args.output_dir) / f"episode_{episode_idx:03d}"
+ output_dir.mkdir(parents=True, exist_ok=True)
+ run_policy(args, env, client, temporal_ensembler, cond_obs_queues,
+ output_dir)
+ finally:
+ env.close()
+ env.close()
+
+
+def get_parser() -> argparse.ArgumentParser:
+ parser = argparse.ArgumentParser()
+ parser.add_argument("--robot_type",
+ type=str,
+ default="g1_dex1",
+ help="The type of the robot embodiment.")
+ parser.add_argument(
+ "--action_horizon",
+ type=int,
+ default=16,
+ help="Number of future actions, predicted by the policy, to keep",
+ )
+ parser.add_argument(
+ "--exe_steps",
+ type=int,
+ default=16,
+ help=
+ "Number of future actions to execute, which must be less than the above action horizon.",
+ )
+ parser.add_argument(
+ "--observation_horizon",
+ type=int,
+ default=2,
+ help="Number of most recent frames/states to consider.",
+ )
+ parser.add_argument(
+ "--language_instruction",
+ type=str,
+ default="Pack black camera into box",
+ help="The language instruction provided to the policy server.",
+ )
+ parser.add_argument("--num_rollouts_planned",
+ type=int,
+ default=10,
+ help="The number of rollouts to run.")
+ parser.add_argument("--output_dir",
+ type=str,
+ default="./results",
+ help="The directory for saving results.")
+ parser.add_argument("--control_freq",
+ type=float,
+ default=30,
+ help="The Low-level control frequency in Hz.")
+ return parser
+
+
+if __name__ == "__main__":
+ parser = get_parser()
+ args = parser.parse_args()
+ run_eval(args)
diff --git a/unitree_deploy/test/arm/g1/test_g1_arm.py b/unitree_deploy/test/arm/g1/test_g1_arm.py
new file mode 100644
index 0000000..bed16b1
--- /dev/null
+++ b/unitree_deploy/test/arm/g1/test_g1_arm.py
@@ -0,0 +1,105 @@
+import time
+
+import numpy as np
+import pinocchio as pin
+
+from unitree_deploy.robot.robot_configs import g1_motors
+from unitree_deploy.robot_devices.arm.configs import G1ArmConfig
+from unitree_deploy.robot_devices.arm.utils import make_arm_motors_buses_from_configs
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+
+if __name__ == "__main__":
+ # ============== Arm Configuration ==============
+ def g1_dual_arm_default_factory():
+ return {
+ "g1": G1ArmConfig(
+ init_pose=np.zeros(14),
+ motors=g1_motors,
+ mock=False,
+ ),
+ }
+
+ # ==============================================
+
+ # Initialize and connect to the robotic arm
+ arm = make_arm_motors_buses_from_configs(g1_dual_arm_default_factory())
+ for name in arm:
+ arm[name].connect()
+ time.sleep(1.5)
+ print("β
Arm connected. Waiting to start...")
+
+ # Define initial target poses for left and right arms
+ L_tf_target = pin.SE3(
+ pin.Quaternion(1, 0, 0, 0),
+ np.array([0.25, +0.25, 0.1]),
+ )
+
+ R_tf_target = pin.SE3(
+ pin.Quaternion(1, 0, 0, 0),
+ np.array([0.25, -0.25, 0.1]),
+ )
+
+ rotation_speed = 0.005 # Rotation speed in radians per iteration
+
+ # Motion parameters
+ control_dt = 1 / 50 # Control cycle duration (20ms)
+ step = 0
+ max_step = 240
+
+ initial_data_received = True # Used to switch from drive to schedule mode
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ try:
+ while True:
+ # Define timing for the control cycle
+ t_cycle_end = time.monotonic() + control_dt
+ t_command_target = t_cycle_end + control_dt
+
+ direction = 1 if step <= 120 else -1
+ angle = rotation_speed * (step if step <= 120 else (240 - step))
+
+ cos_half_angle = np.cos(angle / 2)
+ sin_half_angle = np.sin(angle / 2)
+
+ L_quat = pin.Quaternion(cos_half_angle, 0, sin_half_angle, 0) # η» Y 轴ζ转
+ R_quat = pin.Quaternion(cos_half_angle, 0, 0, sin_half_angle) # η» Z 轴ζ转
+
+ delta_l = np.array([0.001, 0.001, 0.001]) * direction
+ delta_r = np.array([0.001, -0.001, 0.001]) * direction
+
+ L_tf_target.translation += delta_l
+ R_tf_target.translation += delta_r
+
+ L_tf_target.rotation = L_quat.toRotationMatrix()
+ R_tf_target.rotation = R_quat.toRotationMatrix()
+
+ # Solve inverse kinematics for the arm
+ for name in arm:
+ sol_q, sol_tauff = arm[name].arm_ik(L_tf_target.homogeneous, R_tf_target.homogeneous)
+ print(f"Arm {name} solution: q={sol_q}, tauff={sol_tauff}")
+ # Determine command mode
+ cmd_target = "drive_to_waypoint" if initial_data_received else "schedule_waypoint"
+
+ # Send joint target command to arm
+ arm[name].write_arm(
+ q_target=sol_q,
+ tauff_target=sol_tauff, # Optional: send torque feedforward
+ time_target=t_command_target - time.monotonic() + time.perf_counter(),
+ cmd_target="schedule_waypoint",
+ )
+
+ # Update step and reset after full cycle
+ step = (step + 1) % (max_step + 1)
+ initial_data_received = False
+
+ # Wait until end of control cycle
+ precise_wait(t_cycle_end)
+
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ print("\nπ Ctrl+C detected. Disconnecting arm...")
+ for name in arm:
+ arm[name].disconnect()
+
+ print("β
Arm disconnected. Exiting.")
diff --git a/unitree_deploy/test/arm/g1/test_g1_env.py b/unitree_deploy/test/arm/g1/test_g1_env.py
new file mode 100644
index 0000000..87e68a9
--- /dev/null
+++ b/unitree_deploy/test/arm/g1/test_g1_env.py
@@ -0,0 +1,91 @@
+import math
+import time
+
+import numpy as np
+import pinocchio as pin
+
+from unitree_deploy.real_unitree_env import make_real_env
+from unitree_deploy.utils.rerun_visualizer import RerunLogger, flatten_images, visualization_data
+from unitree_deploy.utils.rich_logger import log_info
+from unitree_deploy.utils.trajectory_generator import sinusoidal_gripper_motion
+
+if __name__ == "__main__":
+ period = 2.0
+ motion_period = 2.0
+ motion_amplitude = 0.99
+
+ rerun_logger = RerunLogger()
+ env = make_real_env(robot_type="g1_dex1", dt=1 / 30)
+ env.connect()
+
+ # Define initial target poses for left and right arms
+ L_tf_target = pin.SE3(
+ pin.Quaternion(1, 0, 0, 0),
+ np.array([0.25, +0.25, 0.1]),
+ )
+
+ R_tf_target = pin.SE3(
+ pin.Quaternion(1, 0, 0, 0),
+ np.array([0.25, -0.25, 0.1]),
+ )
+
+ rotation_speed = 0.005 # Rotation speed in radians per iteration
+
+ # Motion parameters
+ control_dt = 1 / 50 # Control cycle duration (20ms)
+ step = 0
+ max_step = 240
+
+ initial_data_received = True # Used to switch from drive to schedule mode
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ try:
+ current_time = math.pi / 2
+ idx = 0 # Initialize index for logging
+ while True:
+ # Define timing for the control cycle
+ t_cycle_end = time.monotonic() + control_dt
+ t_command_target = t_cycle_end + control_dt
+
+ direction = 1 if step <= 120 else -1
+ angle = rotation_speed * (step if step <= 120 else (240 - step))
+
+ cos_half_angle = np.cos(angle / 2)
+ sin_half_angle = np.sin(angle / 2)
+
+ L_quat = pin.Quaternion(cos_half_angle, 0, sin_half_angle, 0) # η» Y 轴ζ转
+ R_quat = pin.Quaternion(cos_half_angle, 0, 0, sin_half_angle) # η» Z 轴ζ转
+
+ delta_l = np.array([0.001, 0.001, 0.001]) * direction
+ delta_r = np.array([0.001, -0.001, 0.001]) * direction
+
+ L_tf_target.translation += delta_l
+ R_tf_target.translation += delta_r
+
+ L_tf_target.rotation = L_quat.toRotationMatrix()
+ R_tf_target.rotation = R_quat.toRotationMatrix()
+
+ # Solve inverse kinematics for the left arm
+ for arm_name in env.robot.arm:
+ arm_sol_q, arm_sol_tauff = env.robot.arm[arm_name].arm_ik(
+ L_tf_target.homogeneous, R_tf_target.homogeneous
+ )
+
+ gripper_target_q = sinusoidal_gripper_motion(
+ period=motion_period, amplitude=motion_amplitude, current_time=time.perf_counter()
+ )
+ action = np.concatenate([arm_sol_q, gripper_target_q], axis=0)
+ step_type, reward, _, observation = env.step(action)
+
+ idx += 1
+ visualization_data(idx, flatten_images(observation), observation["qpos"], arm_sol_q, rerun_logger)
+
+ # Update step and reset after full cycle
+ current_time += control_dt
+ step = (step + 1) % (max_step + 1)
+
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ log_info("\nπ Ctrl+C detected. Disconnecting arm...")
+ env.close()
diff --git a/unitree_deploy/test/arm/z1/test_z1_arm.py b/unitree_deploy/test/arm/z1/test_z1_arm.py
new file mode 100644
index 0000000..5f2e3ef
--- /dev/null
+++ b/unitree_deploy/test/arm/z1/test_z1_arm.py
@@ -0,0 +1,81 @@
+import math
+import time
+
+import numpy as np
+import pinocchio as pin
+
+from unitree_deploy.robot.robot_configs import z1_motors
+from unitree_deploy.robot_devices.arm.utils import make_arm_motors_bus
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+from unitree_deploy.utils.trajectory_generator import generate_rotation, sinusoidal_gripper_motion
+
+if __name__ == "__main__":
+ # ============== Arm Configuration ==============
+ arm_type = "z1"
+ arm_kwargs = {
+ "arm_type": arm_type,
+ "init_pose": [0.00623, 1.11164, -0.77531, -0.32167, -0.005, 0.0, 0.0], # Initial joint pose
+ "motors": z1_motors,
+ }
+ # ==============================================
+
+ # Initialize and connect to the robotic arm
+ arm = make_arm_motors_bus(**arm_kwargs)
+ arm.connect()
+ time.sleep(2)
+ print("β
Arm connected. Waiting to start...")
+
+ # Define arm initial target poses
+ arm_tf_target = pin.SE3(pin.Quaternion(1, 0, 0, 0), np.array([0.2, 0, 0.4]))
+
+ # Motion parameters
+ rotation_speed = 0.01 # Rotation speed (rad per step)
+ control_dt = 1 / 30 # Control cycle duration (20ms)
+ step = 0
+ max_step = 240 # Full motion cycle
+
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ try:
+ current_time = math.pi / 2
+ while True:
+ # Define timing for the control cycle
+ t_cycle_end = time.monotonic() + control_dt
+ t_command_target = t_cycle_end + control_dt
+
+ # Generate target rotation and translation
+ L_quat, R_quat, delta_l, delta_r = generate_rotation(step, rotation_speed, max_step)
+ arm_tf_target.translation += delta_l
+ # delta_r is not used in this context
+ arm_tf_target.rotation = L_quat.toRotationMatrix()
+
+ # Solve inverse kinematics for the left arm
+ arm_sol_q, arm_sol_tauff = arm.arm_ik(arm_tf_target.homogeneous)
+
+ # Generate sinusoidal motion for the gripper
+ target_gripper = (
+ sinusoidal_gripper_motion(period=4.0, amplitude=0.99, current_time=current_time) - 1
+ ) # Adjust target_q by subtracting 1
+
+ target_arm = np.concatenate((arm_sol_q, target_gripper), axis=0) # Add a zero for the gripper
+
+ arm.write_arm(
+ q_target=target_arm,
+ # tauff_target=left_sol_tauff, # Optional: send torque feedforward
+ time_target=t_command_target - time.monotonic() + time.perf_counter(),
+ cmd_target="schedule_waypoint",
+ )
+
+ # Update step and reset after full cycle
+ step = (step + 1) % (max_step + 1)
+ current_time += control_dt
+
+ # Wait until end of control cycle
+ precise_wait(t_cycle_end)
+
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ print("\nπ Ctrl+C detected. Disconnecting arm...")
+ arm.disconnect()
+ print("β
Arm disconnected. Exiting.")
diff --git a/unitree_deploy/test/arm/z1/test_z1_dual_arm.py b/unitree_deploy/test/arm/z1/test_z1_dual_arm.py
new file mode 100644
index 0000000..f9ba0be
--- /dev/null
+++ b/unitree_deploy/test/arm/z1/test_z1_dual_arm.py
@@ -0,0 +1,112 @@
+import time
+
+import numpy as np
+import pinocchio as pin
+
+from unitree_deploy.robot_devices.arm.configs import Z1DualArmConfig
+from unitree_deploy.robot_devices.arm.utils import make_arm_motors_buses_from_configs
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+from unitree_deploy.utils.trajectory_generator import generate_rotation
+
+if __name__ == "__main__":
+ # ============== Arm Configuration ==============
+ def z1_dual_arm_single_config_factory():
+ return {
+ "z1_dual": Z1DualArmConfig(
+ left_robot_ip="127.0.0.1",
+ left_robot_port1=8073,
+ left_robot_port2=8074,
+ right_robot_ip="127.0.0.1",
+ right_robot_port1=8071,
+ right_robot_port2=8072,
+ init_pose_left=[0, 0, 0, 0, 0, 0],
+ init_pose_right=[0, 0, 0, 0, 0, 0],
+ control_dt=1 / 250.0,
+ motors={
+ # name: (index, model)
+ "kLeftWaist": [0, "z1-joint"],
+ "kLeftShoulder": [1, "z1-joint"],
+ "kLeftElbow": [2, "z1-joint"],
+ "kLeftForearmRoll": [3, "z1-joint"],
+ "kLeftWristAngle": [4, "z1-joint"],
+ "kLeftWristRotate": [5, "z1-joint"],
+ "kRightWaist": [7, "z1-joint"],
+ "kRightShoulder": [8, "z1-joint"],
+ "kRightElbow": [9, "z1-joint"],
+ "kRightForearmRoll": [10, "z1-joint"],
+ "kRightWristAngle": [11, "z1-joint"],
+ "kRightWristRotate": [12, "z1-joint"],
+ },
+ ),
+ }
+
+ # ==============================================
+
+ # Initialize and connect to the robotic arm
+ arm = make_arm_motors_buses_from_configs(z1_dual_arm_single_config_factory())
+ for name in arm:
+ arm[name].connect()
+ time.sleep(1.5)
+
+ print("β
Arm connected. Waiting to start...")
+
+ # Define initial target poses for left and right arms
+ L_tf_target = pin.SE3(pin.Quaternion(1, 0, 0, 0), np.array([0.2, 0, 0.4]))
+ R_tf_target = pin.SE3(pin.Quaternion(1, 0, 0, 0), np.array([0.2, 0, 0.3]))
+
+ # Motion parameters
+ rotation_speed = 0.01 # Rotation speed (rad per step)
+ control_dt = 1 / 30 # Control cycle duration (20ms)
+ step = 0
+ max_step = 240 # Full motion cycle
+ initial_data_received = True # Used to switch from drive to schedule mode
+
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ try:
+ while True:
+ # Define timing for the control cycle
+ t_cycle_end = time.monotonic() + control_dt
+ t_command_target = t_cycle_end + control_dt
+
+ # Generate target rotation and translation
+ L_quat, R_quat, delta_l, delta_r = generate_rotation(step, rotation_speed, max_step)
+
+ # Apply translation deltas to target pose
+ L_tf_target.translation += delta_l
+ R_tf_target.translation += delta_r
+
+ # Apply rotation to target pose
+ L_tf_target.rotation = L_quat.toRotationMatrix()
+ R_tf_target.rotation = R_quat.toRotationMatrix()
+
+ # Solve inverse kinematics for the left arm
+ for name in arm:
+ sol_q, sol_tauff = arm[name].arm_ik(L_tf_target.homogeneous, R_tf_target.homogeneous)
+
+ # Determine command mode
+ cmd_target = "drive_to_waypoint" if initial_data_received else "schedule_waypoint"
+
+ # Send joint target command to arm
+ for name in arm:
+ arm[name].write_arm(
+ q_target=sol_q,
+ # tauff_target=sol_tauff, # Optional: send torque feedforward
+ time_target=t_command_target - time.monotonic() + time.perf_counter(),
+ cmd_target=cmd_target,
+ )
+
+ # Update step and reset after full cycle
+ step = (step + 1) % (max_step + 1)
+ initial_data_received = False
+
+ # Wait until end of control cycle
+ precise_wait(t_cycle_end)
+
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ print("\nπ Ctrl+C detected. Disconnecting arm...")
+ for name in arm:
+ arm[name].disconnect()
+ print("β
Arm disconnected. Exiting.")
diff --git a/unitree_deploy/test/arm/z1/test_z1_env.py b/unitree_deploy/test/arm/z1/test_z1_env.py
new file mode 100644
index 0000000..ca7bf9d
--- /dev/null
+++ b/unitree_deploy/test/arm/z1/test_z1_env.py
@@ -0,0 +1,65 @@
+import math
+import time
+
+import numpy as np
+import pinocchio as pin
+
+from unitree_deploy.real_unitree_env import make_real_env
+from unitree_deploy.utils.rerun_visualizer import RerunLogger, flatten_images, visualization_data
+from unitree_deploy.utils.rich_logger import log_info
+from unitree_deploy.utils.trajectory_generator import generate_rotation, sinusoidal_gripper_motion
+
+if __name__ == "__main__":
+ rerun_logger = RerunLogger()
+ env = make_real_env(robot_type="z1_realsense", dt=1 / 30)
+ env.connect()
+
+ # Define initial target poses for left and right arms
+ arm_tf_target = pin.SE3(pin.Quaternion(1, 0, 0, 0), np.array([0.2, 0, 0.4]))
+
+ # Motion parameters
+ rotation_speed = 0.01 # Rotation speed (rad per step)
+ control_dt = 1 / 30 # Control cycle duration (20ms)
+ step = 0
+ max_step = 240 # Full motion cycle
+
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ try:
+ current_time = math.pi / 2
+ idx = 0 # Initialize index for logging
+ while True:
+ # Define timing for the control cycle
+ t_cycle_end = time.monotonic() + control_dt
+ t_command_target = t_cycle_end + control_dt
+
+ # Generate target rotation and translation
+ L_quat, R_quat, delta_l, delta_r = generate_rotation(step, rotation_speed, max_step)
+ arm_tf_target.translation += delta_l
+ # delta_r is not used in this context
+ arm_tf_target.rotation = L_quat.toRotationMatrix()
+
+ # Solve inverse kinematics for the left arm
+ for arm_name in env.robot.arm:
+ arm_sol_q, arm_sol_tauff = env.robot.arm[arm_name].arm_ik(arm_tf_target.homogeneous)
+
+ # Generate sinusoidal motion for the gripper
+ target_gripper = (
+ sinusoidal_gripper_motion(period=4.0, amplitude=0.99, current_time=current_time) - 1
+ ) # Adjust target_q by subtracting 1
+
+ target_arm = np.concatenate((arm_sol_q, target_gripper), axis=0) # Add a zero for the gripper
+ step_type, reward, _, observation = env.step(target_arm)
+
+ idx += 1
+ visualization_data(idx, flatten_images(observation), observation["qpos"], target_arm, rerun_logger)
+
+ # Update step and reset after full cycle
+ current_time += control_dt
+ step = (step + 1) % (max_step + 1)
+
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ log_info("\nπ Ctrl+C detected. Disconnecting arm...")
+ env.close()
diff --git a/unitree_deploy/test/camera/test_image_client_camera.py b/unitree_deploy/test/camera/test_image_client_camera.py
new file mode 100644
index 0000000..3c3f13a
--- /dev/null
+++ b/unitree_deploy/test/camera/test_image_client_camera.py
@@ -0,0 +1,64 @@
+import time
+
+import cv2
+import numpy as np
+import torch
+from tqdm import tqdm
+
+from unitree_deploy.robot_devices.cameras.configs import ImageClientCameraConfig
+from unitree_deploy.robot_devices.cameras.utils import make_cameras_from_configs
+from unitree_deploy.utils.rich_logger import log_success
+
+
+# ============================From configs============================
+def run_camera():
+ def image_client_default_factory():
+ return {
+ "imageclient": ImageClientCameraConfig(
+ head_camera_type="opencv",
+ head_camera_id_numbers=[4],
+ head_camera_image_shape=[480, 1280], # Head camera resolution
+ wrist_camera_type="opencv",
+ wrist_camera_id_numbers=[0, 2],
+ wrist_camera_image_shape=[480, 640], # Wrist camera resolution
+ aspect_ratio_threshold=2.0,
+ fps=30,
+ mock=False,
+ ),
+ }
+
+ # ===========================================
+
+ cameras = make_cameras_from_configs(image_client_default_factory())
+ print(cameras)
+ for name in cameras:
+ cameras[name].connect()
+ log_success(f"Connecting {name} cameras.")
+
+ for _ in tqdm(range(20), desc="Camera warming up"):
+ for name in cameras:
+ cameras[name].async_read()
+ time.sleep(1 / 30)
+
+ while True:
+ images = {}
+ for name in cameras:
+ output = cameras[name].async_read()
+ if isinstance(output, dict):
+ for k, v in output.items():
+ images[k] = torch.from_numpy(v)
+ else:
+ images[name] = torch.from_numpy(output)
+
+ image_list = [np.stack([img.numpy()] * 3, axis=-1) if img.ndim == 2 else img.numpy() for img in images.values()]
+
+ stacked_image = np.hstack(image_list)
+ cv2.imshow("Stacked Image", stacked_image)
+
+ if (cv2.waitKey(1) & 0xFF) == ord("q"):
+ cv2.destroyAllWindows()
+ break
+
+
+if __name__ == "__main__":
+ run_camera()
diff --git a/unitree_deploy/test/camera/test_realsense_camera.py b/unitree_deploy/test/camera/test_realsense_camera.py
new file mode 100644
index 0000000..8f87639
--- /dev/null
+++ b/unitree_deploy/test/camera/test_realsense_camera.py
@@ -0,0 +1,66 @@
+import time
+
+import cv2
+import numpy as np
+
+from unitree_deploy.robot_devices.cameras.configs import IntelRealSenseCameraConfig
+from unitree_deploy.robot_devices.cameras.utils import make_cameras_from_configs
+from unitree_deploy.utils.rich_logger import log_success
+
+
+def run_camera():
+ # ===========================================
+ def intelrealsense_camera_default_factory():
+ return {
+ "cam_high": IntelRealSenseCameraConfig(
+ serial_number="044122071036",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_wrist": IntelRealSenseCameraConfig(
+ serial_number="419122270615",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ }
+
+ # ===========================================
+
+ cameras = make_cameras_from_configs(intelrealsense_camera_default_factory())
+ for name in cameras:
+ cameras[name].connect()
+ log_success(f"Connecting {name} cameras.")
+
+ for _ in range(20):
+ for name in cameras:
+ cameras[name].async_read()
+ time.sleep(1 / 30)
+
+ while True:
+ images = []
+ for name in cameras:
+ frame = cameras[name].async_read()
+ if frame is not None:
+ frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
+ cv2.putText(frame, name, (10, 25), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (0, 255, 0), 2)
+ images.append(frame)
+
+ if images:
+ rows = []
+ for i in range(0, len(images), 2):
+ row = np.hstack(images[i : i + 2])
+ rows.append(row)
+ canvas = np.vstack(rows)
+
+ cv2.imshow("All Cameras", canvas)
+
+ if cv2.waitKey(1) & 0xFF == ord("q"):
+ break
+
+ cv2.destroyAllWindows()
+
+
+if __name__ == "__main__":
+ run_camera()
diff --git a/unitree_deploy/test/camera/test_usb_camera.py b/unitree_deploy/test/camera/test_usb_camera.py
new file mode 100644
index 0000000..e6c47ff
--- /dev/null
+++ b/unitree_deploy/test/camera/test_usb_camera.py
@@ -0,0 +1,95 @@
+import time
+
+import cv2
+import numpy as np
+import tyro
+from tqdm import tqdm
+
+from unitree_deploy.robot_devices.cameras.configs import OpenCVCameraConfig
+from unitree_deploy.robot_devices.cameras.utils import make_camera, make_cameras_from_configs
+from unitree_deploy.utils.rich_logger import log_success
+
+
+def usb_camera_default_factory():
+ return {
+ "cam_high": OpenCVCameraConfig(
+ camera_index="/dev/video1",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_left_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video3",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_right_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video5",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ }
+
+
+def run_cameras(camera_style: int = 0):
+ """
+ Runs camera(s) based on the specified style.
+
+ Args:
+ camera_style (int):
+ 0 - Single camera (OpenCV).
+ 1 - Multiple cameras from config.
+ """
+
+ if camera_style == 0:
+ # ========== Single camera ==========
+ camera_kwargs = {"camera_type": "opencv", "camera_index": "/dev/video5", "mock": False}
+ camera = make_camera(**camera_kwargs)
+ camera.connect()
+ log_success("Connecting camera.")
+
+ while True:
+ color_image = camera.read()
+ color_image = cv2.cvtColor(color_image, cv2.COLOR_BGR2RGB)
+ cv2.imshow("Camera", color_image)
+ if cv2.waitKey(1) & 0xFF == ord("q"):
+ break
+
+ elif camera_style == 1:
+ # ========== Multi-camera from configs ==========
+ cameras = make_cameras_from_configs(usb_camera_default_factory())
+
+ for name in cameras:
+ cameras[name].connect()
+ log_success(f"Connecting {name} camera.")
+
+ # Camera warm-up
+ for _ in tqdm(range(20), desc="Camera warming up"):
+ for name in cameras:
+ cameras[name].async_read()
+ time.sleep(1 / 30)
+
+ while True:
+ images = {}
+ for name in cameras:
+ images[name] = cameras[name].async_read()
+
+ image_list = [
+ np.stack([img.numpy()] * 3, axis=-1) if img.ndim == 2 else img.numpy() for img in images.values()
+ ]
+
+ stacked_image = np.hstack(image_list)
+ cv2.imshow("Multi-Camera View", stacked_image)
+
+ if (cv2.waitKey(1) & 0xFF) == ord("q"):
+ cv2.destroyAllWindows()
+ break
+
+ else:
+ raise ValueError(f"Unsupported camera_style: {camera_style}")
+
+
+if __name__ == "__main__":
+ tyro.cli(run_cameras)
diff --git a/unitree_deploy/test/endeffector/test_dex1.py b/unitree_deploy/test/endeffector/test_dex1.py
new file mode 100644
index 0000000..6a6c0a1
--- /dev/null
+++ b/unitree_deploy/test/endeffector/test_dex1.py
@@ -0,0 +1,60 @@
+import time
+
+import tyro
+
+from unitree_deploy.robot_devices.endeffector.utils import (
+ Dex1_GripperConfig,
+ make_endeffector_motors_buses_from_configs,
+)
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+from unitree_deploy.utils.rich_logger import log_success
+from unitree_deploy.utils.trajectory_generator import sinusoidal_single_gripper_motion
+
+period = 2.0
+motion_period = 2.0
+motion_amplitude = 0.99
+
+
+def gripper_default_factory():
+ return {
+ "left": Dex1_GripperConfig(
+ unit_test=True,
+ motors={
+ "kLeftGripper": [0, "z1_gripper-joint"],
+ },
+ topic_gripper_state="rt/dex1/left/state",
+ topic_gripper_command="rt/dex1/left/cmd",
+ ),
+ "right": Dex1_GripperConfig(
+ unit_test=True,
+ motors={
+ "kRightGripper": [1, "z1_gripper-joint"],
+ },
+ topic_gripper_state="rt/dex1/right/state",
+ topic_gripper_command="rt/dex1/right/cmd",
+ ),
+ }
+
+
+def run_gripper():
+ control_dt = 1 / 30
+
+ log_success("Running gripper in style 1 (multi-bus from config)")
+ endeffectors = make_endeffector_motors_buses_from_configs(gripper_default_factory())
+
+ for name in endeffectors:
+ endeffectors[name].connect()
+ log_success(f"Connected endeffector '{name}'.")
+
+ while True:
+ t_cycle_end = time.monotonic() + control_dt
+ target_q = sinusoidal_single_gripper_motion(
+ period=motion_period, amplitude=motion_amplitude, current_time=time.perf_counter()
+ )
+ for name in endeffectors:
+ endeffectors[name].write_endeffector(q_target=target_q)
+ precise_wait(t_cycle_end)
+
+
+if __name__ == "__main__":
+ tyro.cli(run_gripper)
diff --git a/unitree_deploy/test/test_replay.py b/unitree_deploy/test/test_replay.py
new file mode 100644
index 0000000..3d5eb68
--- /dev/null
+++ b/unitree_deploy/test/test_replay.py
@@ -0,0 +1,44 @@
+"""
+python test/test_replay.py --repo-id unitreerobotics/G1_CameraPackaging_NewDataset --robot_type g1_dex1
+python test/test_replay.py --repo-id unitreerobotics/Z1_StackBox_Dataset --robot_type z1_realsense
+python test/test_replay.py --repo-id unitreerobotics/Z1_Dual_Dex1_StackBox_Dataset_V2 --robot_type z1_dual_dex1_realsense
+"""
+
+import tyro
+from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+from unitree_deploy.real_unitree_env import make_real_env
+from unitree_deploy.utils.rerun_visualizer import RerunLogger, flatten_images, visualization_data
+from unitree_deploy.utils.rich_logger import log_info
+
+
+# Replay a specific episode from the LeRobot dataset using the real environment robot_typeοΌ(e.g., g1_dex1, z1_realsense, z1_dual_dex1_realsense)
+def replay_lerobot_data(repo_id: str, robot_type: str, root: str | None = None, episode: int = 145):
+ dataset = LeRobotDataset(repo_id, root=root, episodes=[episode])
+ actions = dataset.hf_dataset.select_columns("action")
+ init_pose_arm = actions[0]["action"].numpy()[:14] if robot_type == "g1" else actions[0]["action"].numpy()
+ rerun_logger = RerunLogger()
+
+ env = make_real_env(robot_type=robot_type, dt=1 / 30, init_pose_arm=init_pose_arm)
+ env.connect()
+
+ try:
+ # Wait for user input to start the motion loop
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program): \n")
+ if user_input.lower() == "s":
+ log_info("Replaying episode")
+ for idx in range(dataset.num_frames):
+ action = actions[idx]["action"].numpy()
+ if robot_type == "z1_realsense":
+ action[-1] = -action[-1]
+ step_type, reward, _, observation = env.step(action)
+ visualization_data(idx, flatten_images(observation), observation["qpos"], action, rerun_logger)
+ env.close()
+ except KeyboardInterrupt:
+ # Handle Ctrl+C to safely disconnect
+ log_info("\nπ Ctrl+C detected. Disconnecting arm...")
+ env.close()
+
+
+if __name__ == "__main__":
+ tyro.cli(replay_lerobot_data)
diff --git a/unitree_deploy/unitree_deploy/eval_dataset_env.py b/unitree_deploy/unitree_deploy/eval_dataset_env.py
new file mode 100644
index 0000000..0bacb6d
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/eval_dataset_env.py
@@ -0,0 +1,105 @@
+import collections
+import time
+
+import matplotlib.pyplot as plt
+import numpy as np
+from lerobot.datasets.lerobot_dataset import LeRobotDataset
+
+from unitree_deploy.utils.rerun_visualizer import RerunLogger, visualization_data
+
+
+def extract_observation(step: dict):
+ observation = {}
+
+ for key, value in step.items():
+ if key.startswith("observation.images."):
+ if isinstance(value, np.ndarray) and value.ndim == 3 and value.shape[-1] in [1, 3]:
+ value = np.transpose(value, (2, 0, 1))
+ observation[key] = value
+
+ elif key == "observation.state":
+ observation[key] = value
+
+ return observation
+
+
+class DatasetEvalEnv:
+ def __init__(self, repo_id: str, episode_index: int = 0, visualization: bool = True):
+ self.dataset = LeRobotDataset(repo_id=repo_id)
+
+ self.visualization = visualization
+ if self.visualization:
+ self.rerun_logger = RerunLogger()
+
+ self.from_idx = self.dataset.episode_data_index["from"][episode_index].item()
+ self.to_idx = self.dataset.episode_data_index["to"][episode_index].item()
+ self.step_idx = self.from_idx
+
+ self.ground_truth_actions = []
+ self.predicted_actions = []
+
+ def get_observation(self):
+ step = self.dataset[self.step_idx]
+ observation = extract_observation(step)
+
+ state = step["observation.state"].numpy()
+ self.ground_truth_actions.append(step["action"].numpy())
+
+ if self.visualization:
+ visualization_data(
+ self.step_idx,
+ observation,
+ observation["observation.state"],
+ step["action"].numpy(),
+ self.rerun_logger,
+ )
+
+ images_observation = {
+ key: value.numpy() for key, value in observation.items() if key.startswith("observation.images.")
+ }
+
+ obs = collections.OrderedDict()
+ obs["qpos"] = state
+ obs["images"] = images_observation
+
+ self.step_idx += 1
+ return obs
+
+ def step(self, action):
+ self.predicted_actions.append(action)
+
+ if self.step_idx == self.to_idx:
+ self._plot_results()
+ exit()
+
+ def _plot_results(self):
+ ground_truth_actions = np.array(self.ground_truth_actions)
+ predicted_actions = np.array(self.predicted_actions)
+
+ n_timesteps, n_dims = ground_truth_actions.shape
+
+ fig, axes = plt.subplots(n_dims, 1, figsize=(12, 4 * n_dims), sharex=True)
+ fig.suptitle("Ground Truth vs Predicted Actions")
+
+ for i in range(n_dims):
+ ax = axes[i] if n_dims > 1 else axes
+ ax.plot(ground_truth_actions[:, i], label="Ground Truth", color="blue")
+ ax.plot(predicted_actions[:, i], label="Predicted", color="red", linestyle="--")
+ ax.set_ylabel(f"Dim {i + 1}")
+ ax.legend()
+
+ axes[-1].set_xlabel("Timestep")
+ plt.tight_layout()
+ plt.savefig("figure.png")
+ time.sleep(1)
+
+
+def make_dataset_eval_env() -> DatasetEvalEnv:
+ return DatasetEvalEnv()
+
+
+if __name__ == "__main__":
+ eval_dataset = DatasetEvalEnv(repo_id="unitreerobotics/G1_Brainco_PickApple_Dataset")
+ while True:
+ observation = eval_dataset.get_observation()
+ eval_dataset.step(observation["qpos"])
diff --git a/unitree_deploy/unitree_deploy/real_unitree_env.py b/unitree_deploy/unitree_deploy/real_unitree_env.py
new file mode 100644
index 0000000..6b3a788
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/real_unitree_env.py
@@ -0,0 +1,81 @@
+import collections
+import time
+from typing import List, Optional
+
+import cv2
+import dm_env
+import numpy as np
+import torch
+
+from unitree_deploy.robot.robot_utils import make_robot
+from unitree_deploy.robot_devices.robots_devices_utils import precise_wait
+from unitree_deploy.utils.rich_logger import log_success
+
+
+class UnitreeEnv:
+ def __init__(
+ self,
+ robot_type: str = "z1_realsense",
+ dt: float = 1 / 30,
+ init_pose_arm: np.ndarray | List[float] | None = None,
+ ):
+ self.control_dt = dt
+ self.init_pose_arm = init_pose_arm
+ self.state: Optional[np.ndarray] = None
+ self.robot_type = robot_type
+ self.robot = make_robot(self.robot_type)
+
+ def connect(self):
+ self.robot.connect()
+
+ def _get_obs(self):
+ observation = self.robot.capture_observation()
+
+ # Process images
+ image_dict = {
+ key.split("observation.images.")[-1]: cv2.cvtColor(value.numpy(), cv2.COLOR_BGR2RGB)
+ for key, value in observation.items()
+ if key.startswith("observation.images.")
+ }
+ # for image_key, image in image_dict.items():
+ # cv2.imwrite(f"{image_key}.png", image)
+
+ # Process state
+ self.state = observation["observation.state"].numpy()
+
+ # Construct observation dictionary
+ obs = collections.OrderedDict(
+ qpos=self.state,
+ qvel=np.zeros_like(self.state),
+ effort=np.zeros_like(self.state),
+ images=image_dict,
+ )
+
+ return obs
+
+ def get_observation(self, t=0):
+ step_type = dm_env.StepType.FIRST if t == 0 else dm_env.StepType.MID
+ return dm_env.TimeStep(step_type=step_type, reward=0, discount=None, observation=self._get_obs())
+
+ def step(self, action) -> dm_env.TimeStep:
+ t_cycle_end = time.monotonic() + self.control_dt
+ t_command_target = t_cycle_end + self.control_dt
+ self.robot.send_action(torch.from_numpy(action), t_command_target)
+ precise_wait(t_cycle_end)
+
+ return dm_env.TimeStep(
+ step_type=dm_env.StepType.MID,
+ reward=0,
+ discount=None,
+ observation=self._get_obs(),
+ )
+
+ def close(self) -> None:
+ self.robot.disconnect()
+ log_success("Robot disconnected successfully! π")
+
+
+def make_real_env(
+ robot_type: str, dt: float | None, init_pose_arm: np.ndarray | List[float] | None = None
+) -> UnitreeEnv:
+ return UnitreeEnv(robot_type, dt, init_pose_arm)
diff --git a/unitree_deploy/unitree_deploy/robot/robot.py b/unitree_deploy/unitree_deploy/robot/robot.py
new file mode 100644
index 0000000..4fa9a5b
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot/robot.py
@@ -0,0 +1,147 @@
+import time
+
+import torch
+
+from unitree_deploy.robot.robot_configs import UnitreeRobotConfig
+from unitree_deploy.robot_devices.arm.utils import make_arm_motors_buses_from_configs
+from unitree_deploy.robot_devices.cameras.utils import make_cameras_from_configs
+from unitree_deploy.robot_devices.endeffector.utils import (
+ make_endeffector_motors_buses_from_configs,
+)
+from unitree_deploy.utils.rich_logger import log_success
+
+
+class UnitreeRobot:
+ def __init__(
+ self,
+ config: UnitreeRobotConfig,
+ ):
+ self.config = config
+ self.robot_type = self.config.type
+ self.cameras = make_cameras_from_configs(self.config.cameras)
+ self.arm = make_arm_motors_buses_from_configs(self.config.arm)
+ self.endeffector = make_endeffector_motors_buses_from_configs(self.config.endeffector)
+
+ self.initial_data_received = True
+
+ def connect(self):
+ if not self.arm and self.endeffector and not self.cameras:
+ raise ValueError(
+ "UnitreeRobot doesn't have any device to connect. See example of usage in docstring of the class."
+ )
+ # Connect the cameras
+ for name in self.cameras:
+ self.cameras[name].connect()
+ log_success(f"Connecting {name} cameras.")
+
+ for _ in range(20):
+ for name in self.cameras:
+ self.cameras[name].async_read()
+ time.sleep(1 / 30)
+
+ for name in self.arm:
+ self.arm[name].connect()
+ log_success(f"Connecting {name} arm.")
+
+ for name in self.endeffector:
+ self.endeffector[name].connect()
+ log_success(f"Connecting {name} endeffector.")
+
+ time.sleep(2)
+ log_success("All Device Connect Success!!!.β
")
+
+ def capture_observation(self):
+ """The returned observations do not have a batch dimension."""
+
+ # Create state by concatenating follower current position
+ state = []
+ arm_state_list = []
+ endeffector_state_list = []
+ for arm_name in self.arm:
+ arm_state = self.arm[arm_name].read_current_arm_q()
+ arm_state_list.append(torch.from_numpy(arm_state))
+
+ for endeffector_name in self.endeffector:
+ endeffector_state = self.endeffector[endeffector_name].read_current_endeffector_q()
+ endeffector_state_list.append(torch.from_numpy(endeffector_state))
+
+ state = (
+ torch.cat(arm_state_list + endeffector_state_list, dim=0)
+ if arm_state_list or endeffector_state_list
+ else torch.tensor([])
+ )
+
+ # Capture images from cameras
+ images = {}
+ for name in self.cameras:
+ output = self.cameras[name].async_read()
+ if isinstance(output, dict):
+ images.update({k: torch.from_numpy(v) for k, v in output.items()})
+ else:
+ images[name] = torch.from_numpy(output)
+
+ # Populate output dictionnaries and format to pytorch
+ obs_dict = {}
+ obs_dict["observation.state"] = state
+ for name, value in images.items():
+ obs_dict[f"observation.images.{name}"] = value
+ return obs_dict
+
+ def send_action(self, action: torch.Tensor, t_command_target: float | None = None) -> torch.Tensor:
+ from_idx_arm = 0
+ to_idx_arm = 0
+ action_sent_arm = []
+ cmd_target = "drive_to_waypoint" if self.initial_data_received else "schedule_waypoint"
+
+ for arm_name in self.arm:
+ to_idx_arm += len(self.arm[arm_name].motor_names)
+ action_arm = action[from_idx_arm:to_idx_arm].numpy()
+ from_idx_arm = to_idx_arm
+
+ action_sent_arm.append(torch.from_numpy(action_arm))
+
+ self.arm[arm_name].write_arm(
+ action_arm,
+ time_target=t_command_target - time.monotonic() + time.perf_counter(),
+ cmd_target=cmd_target,
+ )
+
+ from_idx_endeffector = to_idx_arm
+ to_idx_endeffector = to_idx_arm
+
+ action_endeffector_set = []
+ for endeffector_name in self.endeffector:
+ to_idx_endeffector += len(self.endeffector[endeffector_name].motor_names)
+ action_endeffector = action[from_idx_endeffector:to_idx_endeffector].numpy()
+ from_idx_endeffector = to_idx_endeffector
+
+ action_endeffector_set.append(torch.from_numpy(action_endeffector))
+
+ self.endeffector[endeffector_name].write_endeffector(
+ action_endeffector,
+ time_target=t_command_target - time.monotonic() + time.perf_counter(),
+ cmd_target=cmd_target,
+ )
+
+ self.initial_data_received = False
+
+ return torch.cat(action_sent_arm + action_endeffector_set, dim=0)
+
+ def disconnect(self):
+ # disconnect the arms
+ for name in self.arm:
+ self.arm[name].disconnect()
+ log_success(f"disconnect {name} arm.")
+
+ for name in self.endeffector:
+ self.endeffector[name].disconnect()
+ log_success(f"disconnect {name} endeffector.")
+
+ # disconnect the cameras
+ for name in self.cameras:
+ self.cameras[name].disconnect()
+ log_success(f"disconnect {name} cameras.")
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
diff --git a/unitree_deploy/unitree_deploy/robot/robot_configs.py b/unitree_deploy/unitree_deploy/robot/robot_configs.py
new file mode 100644
index 0000000..acc4576
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot/robot_configs.py
@@ -0,0 +1,270 @@
+import abc
+from dataclasses import dataclass, field
+
+import draccus
+import numpy as np
+
+from unitree_deploy.robot_devices.arm.configs import (
+ ArmConfig,
+ G1ArmConfig,
+ Z1ArmConfig,
+ Z1DualArmConfig,
+)
+from unitree_deploy.robot_devices.cameras.configs import (
+ CameraConfig,
+ ImageClientCameraConfig,
+ IntelRealSenseCameraConfig,
+ OpenCVCameraConfig,
+)
+from unitree_deploy.robot_devices.endeffector.configs import (
+ Dex1_GripperConfig,
+ EndEffectorConfig,
+)
+
+# ======================== arm motors =================================
+# name: (index, model)
+g1_motors = {
+ "kLeftShoulderPitch": [0, "g1-joint"],
+ "kLeftShoulderRoll": [1, "g1-joint"],
+ "kLeftShoulderYaw": [2, "g1-joint"],
+ "kLeftElbow": [3, "g1-joint"],
+ "kLeftWristRoll": [4, "g1-joint"],
+ "kLeftWristPitch": [5, "g1-joint"],
+ "kLeftWristyaw": [6, "g1-joint"],
+ "kRightShoulderPitch": [7, "g1-joint"],
+ "kRightShoulderRoll": [8, "g1-joint"],
+ "kRightShoulderYaw": [9, "g1-joint"],
+ "kRightElbow": [10, "g1-joint"],
+ "kRightWristRoll": [11, "g1-joint"],
+ "kRightWristPitch": [12, "g1-joint"],
+ "kRightWristYaw": [13, "g1-joint"],
+}
+
+z1_motors = {
+ "kWaist": [0, "z1-joint"],
+ "kShoulder": [1, "z1-joint"],
+ "kElbow": [2, "z1-joint"],
+ "kForearmRoll": [3, "z1-joint"],
+ "kWristAngle": [4, "z1-joint"],
+ "kWristRotate": [5, "z1-joint"],
+ "kGripper": [6, "z1-joint"],
+}
+
+z1_dual_motors = {
+ "kLeftWaist": [0, "z1-joint"],
+ "kLeftShoulder": [1, "z1-joint"],
+ "kLeftElbow": [2, "z1-joint"],
+ "kLeftForearmRoll": [3, "z1-joint"],
+ "kLeftWristAngle": [4, "z1-joint"],
+ "kLeftWristRotate": [5, "z1-joint"],
+ "kRightWaist": [7, "z1-joint"],
+ "kRightShoulder": [8, "z1-joint"],
+ "kRightElbow": [9, "z1-joint"],
+ "kRightForearmRoll": [10, "z1-joint"],
+ "kRightWristAngle": [11, "z1-joint"],
+ "kRightWristRotate": [12, "z1-joint"],
+}
+# =========================================================
+
+
+# ======================== camera =================================
+
+
+def z1_intelrealsense_camera_default_factory():
+ return {
+ "cam_high": IntelRealSenseCameraConfig(
+ serial_number="044122071036",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ # "cam_wrist": IntelRealSenseCameraConfig(
+ # serial_number="419122270615",
+ # fps=30,
+ # width=640,
+ # height=480,
+ # ),
+ }
+
+
+def z1_dual_intelrealsense_camera_default_factory():
+ return {
+ # "cam_left_wrist": IntelRealSenseCameraConfig(
+ # serial_number="218722271166",
+ # fps=30,
+ # width=640,
+ # height=480,
+ # ),
+ # "cam_right_wrist": IntelRealSenseCameraConfig(
+ # serial_number="419122270677",
+ # fps=30,
+ # width=640,
+ # height=480,
+ # ),
+ "cam_high": IntelRealSenseCameraConfig(
+ serial_number="947522071393",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ }
+
+
+def g1_image_client_default_factory():
+ return {
+ "imageclient": ImageClientCameraConfig(
+ head_camera_type="opencv",
+ head_camera_id_numbers=[4],
+ head_camera_image_shape=[480, 1280], # Head camera resolution
+ wrist_camera_type="opencv",
+ wrist_camera_id_numbers=[0, 2],
+ wrist_camera_image_shape=[480, 640], # Wrist camera resolution
+ aspect_ratio_threshold=2.0,
+ fps=30,
+ mock=False,
+ ),
+ }
+
+
+def usb_camera_default_factory():
+ return {
+ "cam_high": OpenCVCameraConfig(
+ camera_index="/dev/video1",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_left_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video5",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ "cam_right_wrist": OpenCVCameraConfig(
+ camera_index="/dev/video3",
+ fps=30,
+ width=640,
+ height=480,
+ ),
+ }
+
+
+# =========================================================
+
+
+# ======================== endeffector =================================
+
+
+def dex1_default_factory():
+ return {
+ "left": Dex1_GripperConfig(
+ unit_test=True,
+ motors={
+ "kLeftGripper": [0, "z1_gripper-joint"],
+ },
+ topic_gripper_state="rt/dex1/left/state",
+ topic_gripper_command="rt/dex1/left/cmd",
+ ),
+ "right": Dex1_GripperConfig(
+ unit_test=True,
+ motors={
+ "kRightGripper": [1, "z1_gripper-joint"],
+ },
+ topic_gripper_state="rt/dex1/right/state",
+ topic_gripper_command="rt/dex1/right/cmd",
+ ),
+ }
+
+
+# =========================================================
+
+# ======================== arm =================================
+
+
+def z1_arm_default_factory(init_pose=None):
+ return {
+ "z1": Z1ArmConfig(
+ init_pose=np.zeros(7) if init_pose is None else init_pose,
+ motors=z1_motors,
+ ),
+ }
+
+
+def z1_dual_arm_single_config_factory(init_pose=None):
+ return {
+ "z1_dual": Z1DualArmConfig(
+ left_robot_ip="127.0.0.1",
+ left_robot_port1=8073,
+ left_robot_port2=8074,
+ right_robot_ip="127.0.0.1",
+ right_robot_port1=8071,
+ right_robot_port2=8072,
+ init_pose_left=np.zeros(6) if init_pose is None else init_pose[:6],
+ init_pose_right=np.zeros(6) if init_pose is None else init_pose[6:],
+ control_dt=1 / 250.0,
+ motors=z1_dual_motors,
+ ),
+ }
+
+
+def g1_dual_arm_default_factory(init_pose=None):
+ return {
+ "g1": G1ArmConfig(
+ init_pose=np.zeros(14) if init_pose is None else init_pose,
+ motors=g1_motors,
+ mock=False,
+ ),
+ }
+
+
+# =========================================================
+
+
+# robot_type: arm devies _ endeffector devies _ camera devies
+@dataclass
+class RobotConfig(draccus.ChoiceRegistry, abc.ABC):
+ @property
+ def type(self) -> str:
+ return self.get_choice_name(self.__class__)
+
+
+@dataclass
+class UnitreeRobotConfig(RobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=lambda: {})
+ arm: dict[str, ArmConfig] = field(default_factory=lambda: {})
+ endeffector: dict[str, EndEffectorConfig] = field(default_factory=lambda: {})
+
+
+# =============================== Single-arm:z1, Camera:Realsense ========================================
+@RobotConfig.register_subclass("z1_realsense")
+@dataclass
+class Z1_Realsense_RobotConfig(UnitreeRobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=z1_intelrealsense_camera_default_factory)
+ arm: dict[str, ArmConfig] = field(default_factory=z1_arm_default_factory)
+
+
+# =============================== Dual-arm:z1, Endeffector:dex1, Camera:Realsense ========================================
+@RobotConfig.register_subclass("z1_dual_dex1_realsense")
+@dataclass
+class Z1dual_Dex1_Realsense_RobotConfig(UnitreeRobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=z1_dual_intelrealsense_camera_default_factory)
+ arm: dict[str, ArmConfig] = field(default_factory=z1_dual_arm_single_config_factory)
+ endeffector: dict[str, EndEffectorConfig] = field(default_factory=dex1_default_factory)
+
+
+# =============================== Dual-arm:z1, Endeffector:dex1, Camera:Realsense ========================================
+@RobotConfig.register_subclass("z1_dual_dex1_opencv")
+@dataclass
+class Z1dual_Dex1_Opencv_RobotConfig(UnitreeRobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=usb_camera_default_factory)
+ arm: dict[str, ArmConfig] = field(default_factory=z1_dual_arm_single_config_factory)
+ endeffector: dict[str, EndEffectorConfig] = field(default_factory=dex1_default_factory)
+
+
+# =============================== Arm:g1, Endeffector:dex1, Camera:imageclint ========================================
+@RobotConfig.register_subclass("g1_dex1")
+@dataclass
+class G1_Dex1_Imageclint_RobotConfig(UnitreeRobotConfig):
+ cameras: dict[str, CameraConfig] = field(default_factory=g1_image_client_default_factory)
+ arm: dict[str, ArmConfig] = field(default_factory=g1_dual_arm_default_factory)
+ endeffector: dict[str, EndEffectorConfig] = field(default_factory=dex1_default_factory)
diff --git a/unitree_deploy/unitree_deploy/robot/robot_utils.py b/unitree_deploy/unitree_deploy/robot/robot_utils.py
new file mode 100644
index 0000000..746a5e5
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot/robot_utils.py
@@ -0,0 +1,47 @@
+from typing import Protocol
+
+from unitree_deploy.robot.robot_configs import (
+ G1_Dex1_Imageclint_RobotConfig,
+ RobotConfig,
+ Z1_Realsense_RobotConfig,
+ Z1dual_Dex1_Opencv_RobotConfig,
+ Z1dual_Dex1_Realsense_RobotConfig,
+)
+
+
+def get_arm_id(name, arm_type):
+ return f"{name}_{arm_type}"
+
+
+class Robot(Protocol):
+ robot_type: str
+ features: dict
+
+ def connect(self): ...
+ def capture_observation(self): ...
+ def send_action(self, action): ...
+ def disconnect(self): ...
+
+
+def make_robot_config(robot_type: str, **kwargs) -> RobotConfig:
+ if robot_type == "z1_realsense":
+ return Z1_Realsense_RobotConfig(**kwargs)
+ elif robot_type == "z1_dual_dex1_realsense":
+ return Z1dual_Dex1_Realsense_RobotConfig(**kwargs)
+ elif robot_type == "z1_dual_dex1_opencv":
+ return Z1dual_Dex1_Opencv_RobotConfig(**kwargs)
+ elif robot_type == "g1_dex1":
+ return G1_Dex1_Imageclint_RobotConfig(**kwargs)
+ else:
+ raise ValueError(f"Robot type '{robot_type}' is not available.")
+
+
+def make_robot_from_config(config: RobotConfig):
+ from unitree_deploy.robot.robot import UnitreeRobot
+
+ return UnitreeRobot(config)
+
+
+def make_robot(robot_type: str, **kwargs) -> Robot:
+ config = make_robot_config(robot_type, **kwargs)
+ return make_robot_from_config(config)
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/arm_indexs.py b/unitree_deploy/unitree_deploy/robot_devices/arm/arm_indexs.py
new file mode 100644
index 0000000..adcfdbd
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/arm_indexs.py
@@ -0,0 +1,119 @@
+# noqa: N815
+from enum import IntEnum
+
+
+# ==================g1========================
+class G1_29_JointArmIndex(IntEnum):
+ # Left arm
+ kLeftShoulderPitch = 15
+ kLeftShoulderRoll = 16
+ kLeftShoulderYaw = 17
+ kLeftElbow = 18
+ kLeftWristRoll = 19
+ kLeftWristPitch = 20
+ kLeftWristyaw = 21
+
+ # Right arm
+ kRightShoulderPitch = 22
+ kRightShoulderRoll = 23
+ kRightShoulderYaw = 24
+ kRightElbow = 25
+ kRightWristRoll = 26
+ kRightWristPitch = 27
+ kRightWristYaw = 28
+
+
+class G1_29_JointIndex(IntEnum):
+ # Left leg
+ kLeftHipPitch = 0
+ kLeftHipRoll = 1
+ kLeftHipYaw = 2
+ kLeftKnee = 3
+ kLeftAnklePitch = 4
+ kLeftAnkleRoll = 5
+
+ # Right leg
+ kRightHipPitch = 6
+ kRightHipRoll = 7
+ kRightHipYaw = 8
+ kRightKnee = 9
+ kRightAnklePitch = 10
+ kRightAnkleRoll = 11
+
+ kWaistYaw = 12
+ kWaistRoll = 13
+ kWaistPitch = 14
+
+ # Left arm
+ kLeftShoulderPitch = 15
+ kLeftShoulderRoll = 16
+ kLeftShoulderYaw = 17
+ kLeftElbow = 18
+ kLeftWristRoll = 19
+ kLeftWristPitch = 20
+ kLeftWristyaw = 21
+
+ # Right arm
+ kRightShoulderPitch = 22
+ kRightShoulderRoll = 23
+ kRightShoulderYaw = 24
+ kRightElbow = 25
+ kRightWristRoll = 26
+ kRightWristPitch = 27
+ kRightWristYaw = 28
+
+ # not used
+ kNotUsedJoint0 = 29
+ kNotUsedJoint1 = 30
+ kNotUsedJoint2 = 31
+ kNotUsedJoint3 = 32
+ kNotUsedJoint4 = 33
+ kNotUsedJoint5 = 34
+
+
+# ==========================================
+
+
+# ==================z1========================
+class Z1ArmJointIndex(IntEnum):
+ WAIST = 0
+ SHOULDER = 1
+ ELBOW = 2
+ FOREARM_ROLL = 3
+ WRIST_ANGLE = 4
+ WRIST_ROTATE = 5
+
+
+class Z1_12_JointArmIndex(IntEnum):
+ # Left arm
+ kLeftWaist = 0
+ kLeftShoulder = 1
+ kLeftElbow = 2
+ kLeftForearmRoll = 3
+ kLeftWristAngle = 4
+ kLeftWristRotate = 5
+
+ # Right arm
+ kRightWaist = 6
+ kRightShoulder = 7
+ kRightElbow = 8
+ kRightForearmRoll = 9
+ kRightWristAngle = 10
+ kRightWristRotate = 11
+
+
+class Z1GripperArmJointIndex(IntEnum):
+ WAIST = 0
+ SHOULDER = 1
+ ELBOW = 2
+ FOREARM_ROLL = 3
+ WRIST_ANGLE = 4
+ WRIST_ROTATE = 5
+ GRIPPER = 6
+
+
+class Gripper_Sigle_JointIndex(IntEnum):
+ kGripper = 0
+
+
+# ==========================================
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/configs.py b/unitree_deploy/unitree_deploy/robot_devices/arm/configs.py
new file mode 100644
index 0000000..7cc88dd
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/configs.py
@@ -0,0 +1,82 @@
+import abc
+from dataclasses import dataclass
+
+import draccus
+import numpy as np
+
+
+@dataclass
+class ArmConfig(draccus.ChoiceRegistry, abc.ABC):
+ @property
+ def type(self) -> str:
+ return self.get_choice_name(self.__class__)
+
+
+@ArmConfig.register_subclass("z1")
+@dataclass
+class Z1ArmConfig(ArmConfig):
+ motors: dict[str, tuple[int, str]]
+
+ init_pose: list = None
+ unit_test: bool = False
+ control_dt: float = 1 / 500.0
+
+ robot_kp: np.ndarray = np.array([4, 6, 6, 6, 6, 6])
+ robot_kd: np.ndarray = np.array([350, 300, 300, 200, 200, 200])
+ max_pos_speed: float = 180 * (np.pi / 180) * 2
+ log_level: str | int = "ERROR"
+
+ def __post_init__(self):
+ if self.control_dt < 0.002:
+ raise ValueError(f"`control_dt` must > 1/500 (got {self.control_dt})")
+
+
+@ArmConfig.register_subclass("z1_dual")
+@dataclass
+class Z1DualArmConfig(ArmConfig):
+ left_robot_ip: str
+ left_robot_port1: int
+ left_robot_port2: int
+ right_robot_ip: str
+ right_robot_port1: int
+ right_robot_port2: int
+ motors: dict[str, tuple[int, str]]
+
+ robot_kp: np.ndarray = np.array([4, 6, 6, 6, 6, 6])
+ robot_kd: np.ndarray = np.array([350, 300, 300, 200, 200, 200])
+ mock: bool = False
+ unit_test: bool = False
+ init_pose_left: list | None = None
+ init_pose_right: list | None = None
+ max_pos_speed: float = 180 * (np.pi / 180) * 2
+ control_dt: float = 1 / 500.0
+
+ def __post_init__(self):
+ if self.control_dt < 0.002:
+ raise ValueError(f"`control_dt` must > 1/500 (got {self.control_dt})")
+
+
+@ArmConfig.register_subclass("g1")
+@dataclass
+class G1ArmConfig(ArmConfig):
+ motors: dict[str, tuple[int, str]]
+ mock: bool = False
+ unit_test: bool = False
+ init_pose: np.ndarray | list = np.zeros(14)
+
+ control_dt: float = 1 / 500.0
+ max_pos_speed: float = 180 * (np.pi / 180) * 2
+
+ topic_low_command: str = "rt/lowcmd"
+ topic_low_state: str = "rt/lowstate"
+
+ kp_high: float = 300.0
+ kd_high: float = 3.0
+ kp_low: float = 80.0 # 140.0
+ kd_low: float = 3.0 # 3.0
+ kp_wrist: float = 40.0
+ kd_wrist: float = 1.5
+
+ def __post_init__(self):
+ if self.control_dt < 0.002:
+ raise ValueError(f"`control_dt` must > 1/500 (got {self.control_dt})")
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm.py b/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm.py
new file mode 100644
index 0000000..44d0f3b
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm.py
@@ -0,0 +1,385 @@
+import threading
+import time
+from typing import Callable
+
+import numpy as np
+from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelPublisher, ChannelSubscriber # dds
+from unitree_sdk2py.idl.default import unitree_hg_msg_dds__LowCmd_
+from unitree_sdk2py.idl.unitree_hg.msg.dds_ import LowCmd_, LowState_ # idl
+from unitree_sdk2py.utils.crc import CRC
+
+from unitree_deploy.robot_devices.arm.arm_indexs import G1_29_JointArmIndex, G1_29_JointIndex
+from unitree_deploy.robot_devices.arm.configs import G1ArmConfig
+from unitree_deploy.robot_devices.arm.g1_arm_ik import G1_29_ArmIK
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ DataBuffer,
+ MotorState,
+ Robot_Num_Motors,
+ RobotDeviceAlreadyConnectedError,
+)
+from unitree_deploy.utils.joint_trajcetory_inter import JointTrajectoryInterpolator
+from unitree_deploy.utils.rich_logger import log_error, log_info, log_success, log_warning
+from unitree_deploy.utils.run_simulation import MujicoSimulation, get_mujoco_sim_config
+
+
+class G1_29_LowState:
+ def __init__(self):
+ self.motor_state = [MotorState() for _ in range(Robot_Num_Motors.G1_29_Num_Motors)]
+
+
+class G1_29_ArmController:
+ def __init__(self, config: G1ArmConfig):
+ self.motors = config.motors
+ self.mock = config.mock
+ self.unit_test = config.unit_test
+ self.init_pose = config.init_pose
+ self.control_dt = config.control_dt
+
+ self.max_pos_speed = config.max_pos_speed
+
+ self.topic_low_command = config.topic_low_command
+ self.topic_low_state = config.topic_low_state
+
+ self.kp_high = config.kp_high
+ self.kd_high = config.kd_high
+ self.kp_low = config.kp_low
+ self.kd_low = config.kd_low
+ self.kp_wrist = config.kp_wrist
+ self.kd_wrist = config.kd_wrist
+
+ self.all_motor_q = None
+ self.q_target = np.zeros(14)
+ self.dq_target = np.zeros(14)
+ self.tauff_target = np.zeros(14)
+ self.time_target = time.monotonic()
+ self.arm_cmd = "schedule_waypoint"
+
+ self.lowstate_buffer = DataBuffer()
+ self.g1_arm_ik = G1_29_ArmIK(unit_test=self.unit_test, visualization=False)
+
+ self.stop_event = threading.Event()
+ self.ctrl_lock = threading.Lock()
+
+ self.is_connected = False
+
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+
+ def _start_daemon_thread(self, target_fn: Callable[[], None], name: str | None = None) -> threading.Thread:
+ thread = threading.Thread(target=target_fn, name=name)
+ thread.daemon = True
+ thread.start()
+ return thread
+
+ def connect(self):
+ try:
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(
+ "G1_Arm is already connected. Do not call `robot.connect()` twice."
+ )
+ if self.mock:
+ config = get_mujoco_sim_config(robot_type="g1")
+ self.g1 = MujicoSimulation(config)
+ time.sleep(1)
+ else:
+ # initialize lowcmd publisher and lowstate subscriber
+ ChannelFactoryInitialize(0)
+ self.lowcmd_publisher = ChannelPublisher(self.topic_low_command, LowCmd_)
+ self.lowcmd_publisher.Init()
+ self.lowstate_subscriber = ChannelSubscriber(self.topic_low_state, LowState_)
+ self.lowstate_subscriber.Init()
+
+ # initialize subscribe thread
+ self.subscribe_thread = self._start_daemon_thread(
+ self._subscribe_motor_state, name="g1._subscribe_motor_state"
+ )
+
+ while not self.lowstate_buffer.get_data():
+ time.sleep(0.01)
+ log_warning("[G1_29_ArmController] Waiting to subscribe dds...")
+
+ if not self.mock:
+ # initialize hg's lowcmd msg
+ self.crc = CRC()
+ self.msg = unitree_hg_msg_dds__LowCmd_()
+ self.msg.mode_pr = 0
+ self.msg.mode_machine = self._read_mode_machine()
+
+ self.all_motor_q = self._read_current_motor_q()
+ log_info(f"Current all body motor state q:\n{self.all_motor_q} \n")
+ log_info(f"Current two arms motor state q:\n{self.read_current_arm_q()}\n")
+ log_info("Lock all joints except two arms...\n")
+
+ arm_indices = {member.value for member in G1_29_JointArmIndex}
+ for id in G1_29_JointIndex:
+ self.msg.motor_cmd[id].mode = 1
+ if id.value in arm_indices:
+ if self._is_wrist_motor(id):
+ self.msg.motor_cmd[id].kp = self.kp_wrist
+ self.msg.motor_cmd[id].kd = self.kd_wrist
+ else:
+ self.msg.motor_cmd[id].kp = self.kp_low
+ self.msg.motor_cmd[id].kd = self.kd_low
+ else:
+ if self._is_weak_motor(id):
+ self.msg.motor_cmd[id].kp = self.kp_low
+ self.msg.motor_cmd[id].kd = self.kd_low
+ else:
+ self.msg.motor_cmd[id].kp = self.kp_high
+ self.msg.motor_cmd[id].kd = self.kd_high
+ self.msg.motor_cmd[id].q = self.all_motor_q[id]
+ log_info("Lock OK!\n")
+
+ # initialize publish thread
+ self.publish_thread = self._start_daemon_thread(self._ctrl_motor_state, name="g1._ctrl_motor_state")
+ self.is_connected = True
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in G1_29_ArmController.connect: {e}")
+
+ def _subscribe_motor_state(self):
+ try:
+ while not self.stop_event.is_set():
+ lowstate = G1_29_LowState()
+ if self.mock:
+ if self.g1.get_current_positions() is not None and len(self.g1.get_current_positions()) != 0:
+ for motor_id in range(Robot_Num_Motors.G1_29_Num_Motors):
+ lowstate.motor_state[motor_id].q = self.g1.get_current_positions()[motor_id]
+ lowstate.motor_state[motor_id].dq = 0.0
+ else:
+ print("[WARN] get_current_positions() failed: queue is empty.")
+ else:
+ msg = self.lowstate_subscriber.Read()
+ if msg is not None:
+ for id in range(Robot_Num_Motors.G1_29_Num_Motors):
+ lowstate.motor_state[id].q = msg.motor_state[id].q
+ lowstate.motor_state[id].dq = msg.motor_state[id].dq
+ self.lowstate_buffer.set_data(lowstate)
+ time.sleep(self.control_dt)
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in G1_29_ArmController._subscribe_motor_state: {e}")
+
+ def _update_g1_arm(
+ self,
+ arm_q_target: np.ndarray,
+ arm_dq_target: np.ndarray | None = None,
+ arm_tauff_target: np.ndarray | None = None,
+ ):
+ if self.mock:
+ self.g1.set_positions(arm_q_target)
+ else:
+ for idx, id in enumerate(G1_29_JointArmIndex):
+ self.msg.motor_cmd[id].q = arm_q_target[idx]
+ self.msg.motor_cmd[id].dq = arm_dq_target[idx]
+ self.msg.motor_cmd[id].tau = arm_tauff_target[idx]
+
+ self.msg.crc = self.crc.Crc(self.msg)
+ self.lowcmd_publisher.Write(self.msg)
+
+ def _drive_to_waypoint(self, target_pose: np.ndarray, t_insert_time: float):
+ curr_time = time.monotonic() + self.control_dt
+ t_insert = curr_time + t_insert_time
+ self.pose_interp = self.pose_interp.drive_to_waypoint(
+ pose=target_pose,
+ time=t_insert,
+ curr_time=curr_time,
+ max_pos_speed=self.max_pos_speed,
+ )
+
+ while time.monotonic() < t_insert:
+ start_time = time.perf_counter()
+
+ cliped_arm_q_target = self.pose_interp(time.monotonic())
+ self._update_g1_arm(cliped_arm_q_target, self.dq_target, self.tauff_target)
+
+ time.sleep(max(0, (self.control_dt - (time.perf_counter() - start_time))))
+
+ def _schedule_waypoint(
+ self,
+ arm_q_target: np.ndarray,
+ arm_time_target: float,
+ t_now: float,
+ start_time: float,
+ last_waypoint_time: float,
+ arm_tauff_target: np.ndarray | None = None,
+ ) -> float:
+ target_time = time.monotonic() - time.perf_counter() + arm_time_target
+ curr_time = t_now + self.control_dt
+ target_time = max(target_time, curr_time + self.control_dt)
+
+ self.pose_interp = self.pose_interp.schedule_waypoint(
+ pose=arm_q_target,
+ time=target_time,
+ max_pos_speed=self.max_pos_speed,
+ curr_time=curr_time,
+ last_waypoint_time=last_waypoint_time,
+ )
+ last_waypoint_time = target_time
+
+ cliped_arm_q_target = self.pose_interp(t_now)
+ self._update_g1_arm(cliped_arm_q_target, self.dq_target, arm_tauff_target)
+
+ time.sleep(max(0, (self.control_dt - (time.perf_counter() - start_time))))
+
+ def _ctrl_motor_state(self):
+ # wait dds init done !!!
+ time.sleep(2)
+
+ self.pose_interp = JointTrajectoryInterpolator(
+ times=[time.monotonic()], joint_positions=[self.read_current_arm_q()]
+ )
+ self.go_start()
+
+ arm_q_target = self.read_current_arm_q()
+ arm_tauff_target = self.tauff_target
+ arm_time_target = time.monotonic()
+ arm_cmd = "schedule_waypoint"
+
+ last_waypoint_time = time.monotonic()
+ while not self.stop_event.is_set():
+ start_time = time.perf_counter()
+ t_now = time.monotonic()
+
+ with self.ctrl_lock:
+ arm_q_target = self.q_target
+ arm_tauff_target = self.tauff_target
+ arm_time_target = self.time_target
+ arm_cmd = self.arm_cmd
+
+ if arm_cmd == "drive_to_waypoint":
+ self._drive_to_waypoint(target_pose=arm_q_target, t_insert_time=0.8)
+
+ elif arm_cmd == "schedule_waypoint":
+ self._schedule_waypoint(
+ arm_q_target=arm_q_target,
+ arm_time_target=arm_time_target,
+ t_now=t_now,
+ start_time=start_time,
+ last_waypoint_time=last_waypoint_time,
+ arm_tauff_target=arm_tauff_target,
+ )
+
+ # target_time = time.monotonic() - time.perf_counter() + arm_time_target
+ # curr_time = t_now + self.control_dt
+
+ # target_time = max(target_time, curr_time + self.control_dt)
+
+ # self.pose_interp = self.pose_interp.schedule_waypoint(
+ # pose=arm_q_target,
+ # time=target_time,
+ # max_pos_speed=self.max_pos_speed,
+ # curr_time=curr_time,
+ # last_waypoint_time=last_waypoint_time
+ # )
+ # last_waypoint_time = target_time
+
+ # cliped_arm_q_target = self.pose_interp(t_now)
+ # self._update_g1_arm(cliped_arm_q_target, self.dq_target, arm_tauff_target)
+
+ # time.sleep(max(0, (self.control_dt - (time.perf_counter() - start_time))))
+
+ def _read_mode_machine(self):
+ """Return current dds mode machine."""
+ return self.lowstate_subscriber.Read().mode_machine
+
+ def _read_current_motor_q(self) -> np.ndarray | None:
+ """Return current state q of all body motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in G1_29_JointIndex])
+
+ def read_current_arm_q(self) -> np.ndarray | None:
+ """Return current state q of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in G1_29_JointArmIndex])
+
+ def read_current_arm_dq(self) -> np.ndarray | None:
+ """Return current state dq of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].dq for id in G1_29_JointArmIndex])
+
+ def write_arm(
+ self,
+ q_target: list[float] | np.ndarray,
+ tauff_target: list[float] | np.ndarray = None,
+ time_target: float | None = None,
+ cmd_target: str | None = None,
+ ):
+ """Set control target values q & tau of the left and right arm motors."""
+ with self.ctrl_lock:
+ self.q_target = q_target
+ self.tauff_target = tauff_target if tauff_target is not None else self.arm_tau(self.q_target)
+ self.time_target = time_target
+ self.arm_cmd = cmd_target
+
+ def arm_ik(
+ self, l_ee_target: list[float] | np.ndarray, r_ee_target: list[float] | np.ndarray
+ ) -> tuple[np.ndarray, np.ndarray] | None:
+ return self.g1_arm_ik.solve_ik(l_ee_target, r_ee_target, self.read_current_arm_q(), self.read_current_arm_dq())
+
+ def arm_tau(
+ self, current_arm_q: np.ndarray | None = None, current_arm_dq: np.ndarray | None = None
+ ) -> np.ndarray | None:
+ return self.g1_arm_ik.solve_tau(current_arm_q, current_arm_dq)
+
+ def arm_fk(self, q: np.ndarray | None = None) -> np.ndarray | None:
+ pass
+
+ def go_start(self):
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=2.0)
+ log_success("[G1_29_ArmController] Go Start OK!\n")
+
+ def go_home(self):
+ if self.mock:
+ self.stop_event.set()
+ # self.subscribe_thread.join()
+ # self.publish_thread.join()
+
+ time.sleep(1)
+ # self.g1.stop()
+
+ else:
+ self.stop_event.set()
+ self.publish_thread.join()
+
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=2.0)
+ log_success("[G1_29_ArmController] Go Home OK!\n")
+
+ def disconnect(self):
+ self.is_connected = False
+ self.go_home()
+
+ def _is_weak_motor(self, motor_index):
+ weak_motors = [
+ G1_29_JointIndex.kLeftAnklePitch.value,
+ G1_29_JointIndex.kRightAnklePitch.value,
+ # Left arm
+ G1_29_JointIndex.kLeftShoulderPitch.value,
+ G1_29_JointIndex.kLeftShoulderRoll.value,
+ G1_29_JointIndex.kLeftShoulderYaw.value,
+ G1_29_JointIndex.kLeftElbow.value,
+ # Right arm
+ G1_29_JointIndex.kRightShoulderPitch.value,
+ G1_29_JointIndex.kRightShoulderRoll.value,
+ G1_29_JointIndex.kRightShoulderYaw.value,
+ G1_29_JointIndex.kRightElbow.value,
+ ]
+ return motor_index.value in weak_motors
+
+ def _is_wrist_motor(self, motor_index):
+ wrist_motors = [
+ G1_29_JointIndex.kLeftWristRoll.value,
+ G1_29_JointIndex.kLeftWristPitch.value,
+ G1_29_JointIndex.kLeftWristyaw.value,
+ G1_29_JointIndex.kRightWristRoll.value,
+ G1_29_JointIndex.kRightWristPitch.value,
+ G1_29_JointIndex.kRightWristYaw.value,
+ ]
+ return motor_index.value in wrist_motors
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm_ik.py b/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm_ik.py
new file mode 100644
index 0000000..9781401
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/g1_arm_ik.py
@@ -0,0 +1,280 @@
+import casadi
+import meshcat.geometry as mg
+import numpy as np
+import pinocchio as pin
+from pinocchio import casadi as cpin
+from pinocchio.visualize import MeshcatVisualizer
+
+from unitree_deploy.utils.weighted_moving_filter import WeightedMovingFilter
+
+
+class G1_29_ArmIK:
+ def __init__(self, unit_test=False, visualization=False):
+ np.set_printoptions(precision=5, suppress=True, linewidth=200)
+
+ self.unit_test = unit_test
+ self.visualization = visualization
+
+ if not self.unit_test:
+ self.robot = pin.RobotWrapper.BuildFromURDF(
+ "unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.urdf",
+ "unitree_deploy/robot_devices/assets/g1/",
+ )
+ else:
+ self.robot = pin.RobotWrapper.BuildFromURDF(
+ "unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.urdf",
+ "unitree_deploy/robot_devices/assets/g1/",
+ ) # for test
+
+ self.mixed_jointsToLockIDs = [
+ "left_hip_pitch_joint",
+ "left_hip_roll_joint",
+ "left_hip_yaw_joint",
+ "left_knee_joint",
+ "left_ankle_pitch_joint",
+ "left_ankle_roll_joint",
+ "right_hip_pitch_joint",
+ "right_hip_roll_joint",
+ "right_hip_yaw_joint",
+ "right_knee_joint",
+ "right_ankle_pitch_joint",
+ "right_ankle_roll_joint",
+ "waist_yaw_joint",
+ "waist_roll_joint",
+ "waist_pitch_joint",
+ "left_hand_thumb_0_joint",
+ "left_hand_thumb_1_joint",
+ "left_hand_thumb_2_joint",
+ "left_hand_middle_0_joint",
+ "left_hand_middle_1_joint",
+ "left_hand_index_0_joint",
+ "left_hand_index_1_joint",
+ "right_hand_thumb_0_joint",
+ "right_hand_thumb_1_joint",
+ "right_hand_thumb_2_joint",
+ "right_hand_index_0_joint",
+ "right_hand_index_1_joint",
+ "right_hand_middle_0_joint",
+ "right_hand_middle_1_joint",
+ ]
+
+ self.reduced_robot = self.robot.buildReducedRobot(
+ list_of_joints_to_lock=self.mixed_jointsToLockIDs,
+ reference_configuration=np.array([0.0] * self.robot.model.nq),
+ )
+
+ self.reduced_robot.model.addFrame(
+ pin.Frame(
+ "L_ee",
+ self.reduced_robot.model.getJointId("left_wrist_yaw_joint"),
+ pin.SE3(np.eye(3), np.array([0.05, 0, 0]).T),
+ pin.FrameType.OP_FRAME,
+ )
+ )
+
+ self.reduced_robot.model.addFrame(
+ pin.Frame(
+ "R_ee",
+ self.reduced_robot.model.getJointId("right_wrist_yaw_joint"),
+ pin.SE3(np.eye(3), np.array([0.05, 0, 0]).T),
+ pin.FrameType.OP_FRAME,
+ )
+ )
+
+ # for i in range(self.reduced_robot.model.nframes):
+ # frame = self.reduced_robot.model.frames[i]
+ # frame_id = self.reduced_robot.model.getFrameId(frame.name)
+
+ # Creating Casadi models and data for symbolic computing
+ self.cmodel = cpin.Model(self.reduced_robot.model)
+ self.cdata = self.cmodel.createData()
+
+ # Creating symbolic variables
+ self.cq = casadi.SX.sym("q", self.reduced_robot.model.nq, 1)
+ self.cTf_l = casadi.SX.sym("tf_l", 4, 4)
+ self.cTf_r = casadi.SX.sym("tf_r", 4, 4)
+ cpin.framesForwardKinematics(self.cmodel, self.cdata, self.cq)
+
+ # Get the hand joint ID and define the error function
+ self.L_hand_id = self.reduced_robot.model.getFrameId("L_ee")
+ self.R_hand_id = self.reduced_robot.model.getFrameId("R_ee")
+
+ self.translational_error = casadi.Function(
+ "translational_error",
+ [self.cq, self.cTf_l, self.cTf_r],
+ [
+ casadi.vertcat(
+ self.cdata.oMf[self.L_hand_id].translation - self.cTf_l[:3, 3],
+ self.cdata.oMf[self.R_hand_id].translation - self.cTf_r[:3, 3],
+ )
+ ],
+ )
+ self.rotational_error = casadi.Function(
+ "rotational_error",
+ [self.cq, self.cTf_l, self.cTf_r],
+ [
+ casadi.vertcat(
+ cpin.log3(self.cdata.oMf[self.L_hand_id].rotation @ self.cTf_l[:3, :3].T),
+ cpin.log3(self.cdata.oMf[self.R_hand_id].rotation @ self.cTf_r[:3, :3].T),
+ )
+ ],
+ )
+
+ # Defining the optimization problem
+ self.opti = casadi.Opti()
+ self.var_q = self.opti.variable(self.reduced_robot.model.nq)
+ self.var_q_last = self.opti.parameter(self.reduced_robot.model.nq) # for smooth
+ self.param_tf_l = self.opti.parameter(4, 4)
+ self.param_tf_r = self.opti.parameter(4, 4)
+ self.translational_cost = casadi.sumsqr(self.translational_error(self.var_q, self.param_tf_l, self.param_tf_r))
+ self.rotation_cost = casadi.sumsqr(self.rotational_error(self.var_q, self.param_tf_l, self.param_tf_r))
+ self.regularization_cost = casadi.sumsqr(self.var_q)
+ self.smooth_cost = casadi.sumsqr(self.var_q - self.var_q_last)
+
+ # Setting optimization constraints and goals
+ self.opti.subject_to(
+ self.opti.bounded(
+ self.reduced_robot.model.lowerPositionLimit,
+ self.var_q,
+ self.reduced_robot.model.upperPositionLimit,
+ )
+ )
+ self.opti.minimize(
+ 50 * self.translational_cost + self.rotation_cost + 0.02 * self.regularization_cost + 0.1 * self.smooth_cost
+ )
+
+ opts = {
+ "ipopt": {"print_level": 0, "max_iter": 50, "tol": 1e-6},
+ "print_time": False, # print or not
+ "calc_lam_p": False, # https://github.com/casadi/casadi/wiki/FAQ:-Why-am-I-getting-%22NaN-detected%22in-my-optimization%3F
+ }
+ self.opti.solver("ipopt", opts)
+
+ self.init_data = np.zeros(self.reduced_robot.model.nq)
+ self.smooth_filter = WeightedMovingFilter(np.array([0.4, 0.3, 0.2, 0.1]), 14)
+ self.vis = None
+
+ if self.visualization:
+ # Initialize the Meshcat visualizer for visualization
+ self.vis = MeshcatVisualizer(
+ self.reduced_robot.model, self.reduced_robot.collision_model, self.reduced_robot.visual_model
+ )
+ self.vis.initViewer(open=True)
+ self.vis.loadViewerModel("pinocchio")
+ self.vis.displayFrames(True, frame_ids=[101, 102], axis_length=0.15, axis_width=5)
+ self.vis.display(pin.neutral(self.reduced_robot.model))
+
+ # Enable the display of end effector target frames with short axis lengths and greater width.
+ frame_viz_names = ["L_ee_target", "R_ee_target"]
+ frame_axis_positions = (
+ np.array([[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 1]]).astype(np.float32).T
+ )
+ frame_axis_colors = (
+ np.array([[1, 0, 0], [1, 0.6, 0], [0, 1, 0], [0.6, 1, 0], [0, 0, 1], [0, 0.6, 1]]).astype(np.float32).T
+ )
+ axis_length = 0.1
+ axis_width = 10
+ for frame_viz_name in frame_viz_names:
+ self.vis.viewer[frame_viz_name].set_object(
+ mg.LineSegments(
+ mg.PointsGeometry(
+ position=axis_length * frame_axis_positions,
+ color=frame_axis_colors,
+ ),
+ mg.LineBasicMaterial(
+ linewidth=axis_width,
+ vertexColors=True,
+ ),
+ )
+ )
+
+ # If the robot arm is not the same size as your arm :)
+ def scale_arms(self, human_left_pose, human_right_pose, human_arm_length=0.60, robot_arm_length=0.75):
+ scale_factor = robot_arm_length / human_arm_length
+ robot_left_pose = human_left_pose.copy()
+ robot_right_pose = human_right_pose.copy()
+ robot_left_pose[:3, 3] *= scale_factor
+ robot_right_pose[:3, 3] *= scale_factor
+ return robot_left_pose, robot_right_pose
+
+ def solve_ik(self, left_wrist, right_wrist, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
+ if current_lr_arm_motor_q is not None:
+ self.init_data = current_lr_arm_motor_q
+ self.opti.set_initial(self.var_q, self.init_data)
+
+ # left_wrist, right_wrist = self.scale_arms(left_wrist, right_wrist)
+ if self.visualization:
+ self.vis.viewer["L_ee_target"].set_transform(left_wrist) # for visualization
+ self.vis.viewer["R_ee_target"].set_transform(right_wrist) # for visualization
+
+ self.opti.set_value(self.param_tf_l, left_wrist)
+ self.opti.set_value(self.param_tf_r, right_wrist)
+ self.opti.set_value(self.var_q_last, self.init_data) # for smooth
+
+ try:
+ self.opti.solve()
+ # sol = self.opti.solve_limited()
+
+ sol_q = self.opti.value(self.var_q)
+ self.smooth_filter.add_data(sol_q)
+ sol_q = self.smooth_filter.filtered_data
+
+ v = current_lr_arm_motor_dq * 0.0 if current_lr_arm_motor_dq is not None else (sol_q - self.init_data) * 0.0
+
+ self.init_data = sol_q
+
+ sol_tauff = pin.rnea(
+ self.reduced_robot.model,
+ self.reduced_robot.data,
+ sol_q,
+ v,
+ np.zeros(self.reduced_robot.model.nv),
+ )
+
+ if self.visualization:
+ self.vis.display(sol_q) # for visualization
+
+ return sol_q, sol_tauff
+
+ except Exception as e:
+ print(f"ERROR in convergence, plotting debug info.{e}")
+
+ sol_q = self.opti.debug.value(self.var_q)
+ self.smooth_filter.add_data(sol_q)
+ sol_q = self.smooth_filter.filtered_data
+
+ v = current_lr_arm_motor_dq * 0.0 if current_lr_arm_motor_dq is not None else (sol_q - self.init_data) * 0.0
+
+ self.init_data = sol_q
+
+ sol_tauff = pin.rnea(
+ self.reduced_robot.model,
+ self.reduced_robot.data,
+ sol_q,
+ v,
+ np.zeros(self.reduced_robot.model.nv),
+ )
+
+ print(
+ f"sol_q:{sol_q} \nmotorstate: \n{current_lr_arm_motor_q} \nleft_pose: \n{left_wrist} \nright_pose: \n{right_wrist}"
+ )
+ if self.visualization:
+ self.vis.display(sol_q) # for visualization
+
+ # return sol_q, sol_tauff
+ return current_lr_arm_motor_q, np.zeros(self.reduced_robot.model.nv)
+
+ def solve_tau(self, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
+ try:
+ sol_tauff = pin.rnea(
+ self.reduced_robot.model,
+ self.reduced_robot.data,
+ current_lr_arm_motor_q,
+ np.zeros(14),
+ np.zeros(self.reduced_robot.model.nv),
+ )
+ return sol_tauff
+
+ except Exception as e:
+ print(f"ERROR in convergence, plotting debug info.{e}")
+ return np.zeros(self.reduced_robot.model.nv)
\ No newline at end of file
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/utils.py b/unitree_deploy/unitree_deploy/robot_devices/arm/utils.py
new file mode 100644
index 0000000..3a489d4
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/utils.py
@@ -0,0 +1,63 @@
+from typing import Protocol
+
+from unitree_deploy.robot_devices.arm.configs import ArmConfig, G1ArmConfig, Z1ArmConfig, Z1DualArmConfig
+
+
+class Arm(Protocol):
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+
+ def read_current_motor_q(self): ...
+ def read_current_arm_q(self): ...
+ def read_current_arm_dq(self): ...
+ def write_arm(self): ...
+
+ def arm_ik(self): ...
+ def arm_fk(self): ...
+ def go_start(self): ...
+ def go_home(self): ...
+
+
+def make_arm_motors_buses_from_configs(armconfig: dict[str, ArmConfig]) -> list[Arm]:
+ arm_motors_buses = {}
+
+ for key, cfg in armconfig.items():
+ if cfg.type == "z1":
+ from unitree_deploy.robot_devices.arm.z1_arm import Z1ArmController
+
+ arm_motors_buses[key] = Z1ArmController(cfg)
+ elif cfg.type == "g1":
+ from unitree_deploy.robot_devices.arm.g1_arm import G1_29_ArmController
+
+ arm_motors_buses[key] = G1_29_ArmController(cfg)
+ elif cfg.type == "z1_dual":
+ from unitree_deploy.robot_devices.arm.z1_dual_arm import Z1_12_ArmController
+
+ arm_motors_buses[key] = Z1_12_ArmController(cfg)
+ else:
+ raise ValueError(f"The motor type '{cfg.type}' is not valid.")
+
+ return arm_motors_buses
+
+
+def make_arm_motors_bus(arm_type: str, **kwargs) -> Arm:
+ if arm_type == "z1":
+ from unitree_deploy.robot_devices.arm.z1_arm import Z1ArmController
+
+ config = Z1ArmConfig(**kwargs)
+ return Z1ArmController(config)
+
+ elif arm_type == "z1_dual":
+ from unitree_deploy.robot_devices.arm.z1_dual_arm import Z1_12_ArmController
+
+ config = Z1DualArmConfig(**kwargs)
+ return Z1_12_ArmController(config)
+
+ elif arm_type == "g1":
+ from unitree_deploy.robot_devices.arm.g1_arm import G1_29_ArmController
+
+ config = G1ArmConfig(**kwargs)
+ return G1_29_ArmController(config)
+ else:
+ raise ValueError(f"The motor type '{arm_type}' is not valid.")
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm.py b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm.py
new file mode 100644
index 0000000..909c8f8
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm.py
@@ -0,0 +1,294 @@
+import os
+import sys
+import threading
+import time
+from typing import Callable
+
+import numpy as np
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__))))
+import unitree_arm_interface as unitree_z1 # type: ignore
+
+from unitree_deploy.robot_devices.arm.arm_indexs import Z1GripperArmJointIndex
+from unitree_deploy.robot_devices.arm.configs import Z1ArmConfig
+from unitree_deploy.robot_devices.arm.z1_arm_ik import Z1_Arm_IK
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ DataBuffer,
+ MotorState,
+ Robot_Num_Motors,
+ RobotDeviceAlreadyConnectedError,
+)
+from unitree_deploy.utils.joint_trajcetory_inter import JointTrajectoryInterpolator
+from unitree_deploy.utils.rich_logger import RichLogger
+
+
+class Z1LowState:
+ def __init__(self) -> None:
+ self.motor_state: list[MotorState] = [MotorState() for _ in range(Robot_Num_Motors.Z1_7_Num_Motors)]
+
+
+class Z1ArmController:
+ def __init__(self, config: Z1ArmConfig):
+ self.motors = config.motors
+
+ self.init_pose = config.init_pose
+ self.unit_test = config.unit_test
+ self.control_dt = config.control_dt
+
+ self.robot_kp = config.robot_kp
+ self.robot_kd = config.robot_kd
+ self.max_pos_speed = config.max_pos_speed
+ self.log_level = config.log_level
+
+ self.q_target = self.init_pose
+ self.dq_target = np.zeros(len(Z1GripperArmJointIndex) - 1, dtype=np.float16)
+ self.ddq_target = np.zeros(len(Z1GripperArmJointIndex) - 1, dtype=np.float16)
+ self.tauff_target = np.zeros(len(Z1GripperArmJointIndex) - 1, dtype=np.float16)
+ self.ftip_target = np.zeros(len(Z1GripperArmJointIndex) - 1, dtype=np.float16)
+ self.time_target = time.monotonic()
+
+ self.DELTA_GRIPPER_CMD = 5.0 / 20.0 / 25.6
+ self.arm_cmd = "schedule_waypoint"
+
+ self.ctrl_lock = threading.Lock()
+
+ self.lowstate_buffer = DataBuffer()
+ self.z1_arm_ik = Z1_Arm_IK(unit_test=self.unit_test, visualization=False)
+ self.logger = RichLogger(self.log_level)
+
+ self.is_connected = False
+ self.grasped = False
+
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+
+ def _start_daemon_thread(self, target_fn: Callable[[], None], name: str | None = None) -> threading.Thread:
+ thread = threading.Thread(target=target_fn, name=name)
+ thread.daemon = True
+ thread.start()
+ return thread
+
+ def connect(self):
+ try:
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(
+ "Z1_Arm is already connected. Do not run `robot.connect()` twice."
+ )
+ # Initialize arms
+ self.z1 = unitree_z1.ArmInterface()
+ self.z1_model = self.z1._ctrlComp.armModel
+ self.z1.setFsmLowcmd()
+ self.z1.lowcmd.setControlGain(self.robot_kp, self.robot_kd)
+ self.z1.sendRecv()
+
+ self.subscribe_thread = self._start_daemon_thread(
+ self._subscribe_motor_state, name="z1._subscribe_motor_state"
+ )
+ while not self.lowstate_buffer.get_data():
+ time.sleep(0.01)
+ self.logger.warning("[Z1_ArmController] Waiting Get Data...")
+
+ self.publish_thread = self._start_daemon_thread(self._ctrl_motor_state, name="z1._ctrl_motor_state")
+ self.is_connected = True
+
+ except Exception as e:
+ self.disconnect()
+ self.logger.error(f"β Error in Z1ArmController.connect: {e}")
+
+ def _subscribe_motor_state(self):
+ try:
+ while True:
+ lowstate = Z1LowState()
+ for motor_id in range(Robot_Num_Motors.Z1_7_Num_Motors - 1):
+ lowstate.motor_state[motor_id].q = self.z1.lowstate.getQ()[motor_id]
+ lowstate.motor_state[motor_id].dq = self.z1.lowstate.getQd()[motor_id]
+
+ gripper_q = self.z1.lowstate.getGripperQ()
+ lowstate.motor_state[Robot_Num_Motors.Z1_7_Num_Motors - 1].q = gripper_q
+ lowstate.motor_state[Robot_Num_Motors.Z1_7_Num_Motors - 1].dq = 0.0
+
+ self.lowstate_buffer.set_data(lowstate)
+ time.sleep(self.control_dt)
+
+ except Exception as e:
+ self.disconnect()
+ self.logger.error(f"β Error in Z1ArmController._subscribe_motor_state: {e}")
+
+ def _update_z1_arm(
+ self,
+ q: np.ndarray,
+ qd: np.ndarray | None = None,
+ qdd: np.ndarray | None = None,
+ ftip: np.ndarray | None = None,
+ tau: np.ndarray | None = None,
+ ):
+ """Update the state and command of a given robotic arm."""
+ current_gripper_q = self.read_current_gripper_q()
+ self.z1.q = q[: len(Z1GripperArmJointIndex) - 1]
+ self.z1.qd = self.dq_target if qd is None else qd
+ qdd = self.ddq_target if qdd is None else qdd
+ ftip = self.ftip_target if ftip is None else ftip
+ self.z1.tau = self.z1_model.inverseDynamics(self.z1.q, self.z1.qd, qdd, ftip) if tau is None else tau
+ self.z1.setArmCmd(self.z1.q, self.z1.qd, self.z1.tau)
+
+ gripper_q = q[len(Z1GripperArmJointIndex) - 1]
+ self.z1.gripperQ = np.clip(
+ gripper_q,
+ current_gripper_q - self.DELTA_GRIPPER_CMD * 3,
+ current_gripper_q + self.DELTA_GRIPPER_CMD * 3,
+ )
+ # self.z1.gripperQ = np.clip(gripper_q, current_gripper_q - self.DELTA_GRIPPER_CMD, current_gripper_q + self.DELTA_GRIPPER_CMD) if self.grasped else np.clip(gripper_q, current_gripper_q - self.DELTA_GRIPPER_CMD*4, current_gripper_q + self.DELTA_GRIPPER_CMD*4) # np.clip(gripper_q, current_gripper_q - self.DELTA_GRIPPER_CMD*3, current_gripper_q + self.DELTA_GRIPPER_CMD*3)
+ self.z1.setGripperCmd(self.z1.gripperQ, self.z1.gripperQd, self.z1.gripperTau)
+ self.z1.sendRecv()
+ time.sleep(self.control_dt)
+ self.grasped = abs(self.read_current_gripper_q() - current_gripper_q) < self.DELTA_GRIPPER_CMD / 12.0
+
+ def _drive_to_waypoint(self, target_pose: np.ndarray, t_insert_time: float):
+ curr_time = time.monotonic() + self.control_dt
+ t_insert = curr_time + t_insert_time
+ self.pose_interp = self.pose_interp.drive_to_waypoint(
+ pose=target_pose,
+ time=t_insert,
+ curr_time=curr_time,
+ max_pos_speed=self.max_pos_speed,
+ )
+
+ while time.monotonic() < t_insert:
+ self._update_z1_arm(self.pose_interp(time.monotonic()))
+
+ def _schedule_waypoint(
+ self,
+ arm_q_target: np.ndarray,
+ arm_time_target: float,
+ t_now: float,
+ start_time: float,
+ last_waypoint_time: float,
+ arm_tauff_target: np.ndarray | None = None,
+ ) -> float:
+ target_time = time.monotonic() - time.perf_counter() + arm_time_target
+ curr_time = t_now + self.control_dt
+ target_time = max(target_time, curr_time + self.control_dt)
+
+ self.pose_interp = self.pose_interp.schedule_waypoint(
+ pose=arm_q_target,
+ time=target_time,
+ max_pos_speed=self.max_pos_speed,
+ curr_time=curr_time,
+ last_waypoint_time=last_waypoint_time,
+ )
+ last_waypoint_time = target_time
+ self._update_z1_arm(q=self.pose_interp(t_now), tau=arm_tauff_target)
+
+ def _ctrl_motor_state(self):
+ try:
+ self.pose_interp = JointTrajectoryInterpolator(
+ times=[time.monotonic()],
+ joint_positions=[self.read_current_arm_q()],
+ )
+ self.go_start()
+
+ arm_q_target = self.read_current_arm_q()
+ arm_tauff_target = self.tauff_target
+ arm_time_target = time.monotonic()
+ arm_cmd = "schedule_waypoint"
+ last_waypoint_time = time.monotonic()
+
+ while True:
+ start_time = time.perf_counter()
+ t_now = time.monotonic()
+ with self.ctrl_lock:
+ arm_q_target = self.q_target
+ arm_tauff_target = self.tauff_target
+ arm_time_target = self.time_target
+ arm_cmd = self.arm_cmd
+
+ if arm_cmd == "drive_to_waypoint":
+ self._drive_to_waypoint(target_pose=arm_q_target, t_insert_time=0.8)
+
+ elif arm_cmd == "schedule_waypoint":
+ self._schedule_waypoint(
+ arm_q_target=arm_q_target,
+ arm_time_target=arm_time_target,
+ t_now=t_now,
+ start_time=start_time,
+ last_waypoint_time=last_waypoint_time,
+ arm_tauff_target=arm_tauff_target,
+ )
+
+ except Exception as e:
+ self.disconnect()
+ self.logger.error(f"β Error in Z1ArmController._ctrl_motor_state: {e}")
+
+ def read_current_arm_q(self) -> np.ndarray:
+ """Return current state q of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in Z1GripperArmJointIndex])
+
+ def read_current_arm_q_without_gripper(self) -> np.ndarray:
+ """Return current state q of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in list(Z1GripperArmJointIndex)[:-1]])
+
+ def read_current_gripper_q(self) -> np.ndarray:
+ """Return current state q of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[list(Z1GripperArmJointIndex)[-1].value].q])
+
+ def read_current_arm_dq(self) -> np.ndarray:
+ """Return current state dq of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].dq for id in Z1GripperArmJointIndex])
+
+ def read_current_arm_dq_without_gripper(self) -> np.ndarray:
+ """Return current state dq of the left and right arm motors."""
+ return np.array(
+ [self.lowstate_buffer.get_data().motor_state[id].dq for id in list(Z1GripperArmJointIndex)[:-1]]
+ )
+
+ def write_arm(
+ self,
+ q_target: list[float] | np.ndarray,
+ tauff_target: list[float] | np.ndarray = None,
+ time_target: float | None = None,
+ cmd_target: str | None = None,
+ ):
+ """Set control target values q & tau of the left and right arm motors."""
+ with self.ctrl_lock:
+ self.q_target = q_target
+ self.tauff_target = tauff_target
+ self.time_target = time_target
+ self.arm_cmd = cmd_target
+
+ def arm_ik(self, ee_target: list[float] | np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
+ return self.z1_arm_ik.solve_ik(
+ ee_target, self.read_current_arm_q_without_gripper(), self.read_current_arm_dq_without_gripper()
+ )
+
+ def arm_fk(self, q: np.ndarray | None = None) -> np.ndarray | None:
+ return self.z1_model.forwardKinematics(
+ q if q is not None else self.read_current_arm_q(), len(Z1GripperArmJointIndex)
+ )
+
+ def go_start(self):
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=1.0)
+ self.logger.success("Go Start OK!\n")
+
+ def go_home(self):
+ self.z1.loopOn()
+ self.z1.backToStart()
+ self.z1.loopOff()
+ time.sleep(0.5)
+ self.logger.success("Go Home OK!\n")
+
+ def disconnect(self):
+ self.is_connected = False
+ self.go_home()
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm_ik.py b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm_ik.py
new file mode 100644
index 0000000..888860b
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_arm_ik.py
@@ -0,0 +1,253 @@
+import time
+
+import casadi
+import meshcat.geometry as mg
+import numpy as np
+import pinocchio as pin
+from pinocchio import casadi as cpin
+from pinocchio.visualize import MeshcatVisualizer
+
+from unitree_deploy.utils.weighted_moving_filter import WeightedMovingFilter
+
+
+class Z1_Arm_IK:
+ def __init__(self, unit_test=False, visualization=False):
+ np.set_printoptions(precision=5, suppress=True, linewidth=200)
+
+ self.unit_test = unit_test
+ self.visualization = visualization
+
+ self.robot = pin.RobotWrapper.BuildFromURDF(
+ "unitree_deploy/robot_devices/assets/z1/z1.urdf", "unitree_deploy/robot_devices/assets/z1/"
+ )
+ self.mixed_jointsToLockIDs = ["base_static_joint"]
+
+ self.reduced_robot = self.robot.buildReducedRobot(
+ list_of_joints_to_lock=self.mixed_jointsToLockIDs,
+ reference_configuration=np.array([0.0] * self.robot.model.nq),
+ )
+
+ self.reduced_robot.model.addFrame(
+ pin.Frame(
+ "ee",
+ self.reduced_robot.model.getJointId("joint6"),
+ pin.SE3(np.eye(3), np.array([0.15, 0, 0]).T),
+ pin.FrameType.OP_FRAME,
+ )
+ )
+
+ # for i in range(self.reduced_robot.model.nframes):
+ # frame = self.reduced_robot.model.frames[i]
+ # frame_id = self.reduced_robot.model.getFrameId(frame.name)
+ # print(f"Frame ID: {frame_id}, Name: {frame.name}")
+
+ # Creating Casadi models and data for symbolic computing
+ self.cmodel = cpin.Model(self.reduced_robot.model)
+ self.cdata = self.cmodel.createData()
+
+ self.cq = casadi.SX.sym("q", self.reduced_robot.model.nq, 1)
+ self.cTf = casadi.SX.sym("tf", 4, 4)
+
+ cpin.framesForwardKinematics(self.cmodel, self.cdata, self.cq)
+
+ self.EE_ID = self.reduced_robot.model.getFrameId("link06")
+ self.translational_error = casadi.Function(
+ "translational_error",
+ [self.cq, self.cTf],
+ [
+ casadi.vertcat(
+ self.cdata.oMf[self.EE_ID].translation - self.cTf[:3, 3],
+ )
+ ],
+ )
+ self.rotational_error = casadi.Function(
+ "rotational_error",
+ [self.cq, self.cTf],
+ [
+ casadi.vertcat(
+ cpin.log3(self.cdata.oMf[self.EE_ID].rotation @ self.cTf[:3, :3].T),
+ )
+ ],
+ )
+
+ self.opti = casadi.Opti()
+ self.var_q = self.opti.variable(self.reduced_robot.model.nq)
+ self.var_q_last = self.opti.parameter(self.reduced_robot.model.nq) # for smooth
+ self.param_tf = self.opti.parameter(4, 4)
+ self.translational_cost = casadi.sumsqr(self.translational_error(self.var_q, self.param_tf))
+ self.rotation_cost = casadi.sumsqr(self.rotational_error(self.var_q, self.param_tf))
+ self.regularization_cost = casadi.sumsqr(self.var_q)
+ self.smooth_cost = casadi.sumsqr(self.var_q - self.var_q_last)
+
+ # Setting optimization constraints and goals
+ self.opti.subject_to(
+ self.opti.bounded(
+ self.reduced_robot.model.lowerPositionLimit,
+ self.var_q,
+ self.reduced_robot.model.upperPositionLimit,
+ )
+ )
+ self.opti.minimize(
+ 50 * self.translational_cost
+ + self.rotation_cost
+ + 0.02 * self.regularization_cost
+ + 0.1 * self.smooth_cost
+ )
+ # self.opti.minimize(20 * self.cost + self.regularization_cost)
+
+ opts = {
+ "ipopt": {"print_level": 0, "max_iter": 50, "tol": 1e-6},
+ "print_time": False, # print or not
+ "calc_lam_p": False, # https://github.com/casadi/casadi/wiki/FAQ:-Why-am-I-getting-%22NaN-detected%22in-my-optimization%3F
+ }
+ self.opti.solver("ipopt", opts)
+
+ self.init_data = np.zeros(self.reduced_robot.model.nq)
+ self.smooth_filter = WeightedMovingFilter(np.array([0.4, 0.3, 0.2, 0.1]), 6)
+
+ self.vis = None
+
+ if self.visualization:
+ # Initialize the Meshcat visualizer for visualization
+ self.vis = MeshcatVisualizer(
+ self.reduced_robot.model, self.reduced_robot.collision_model, self.reduced_robot.visual_model
+ )
+ self.vis.initViewer(open=True)
+ self.vis.loadViewerModel("pinocchio")
+ self.vis.displayFrames(True, frame_ids=[101, 102], axis_length=0.15, axis_width=5)
+ self.vis.display(pin.neutral(self.reduced_robot.model))
+
+ # Enable the display of end effector target frames with short axis lengths and greater width.
+ frame_viz_names = ["ee_target"]
+ frame_axis_positions = (
+ np.array([[0, 0, 0], [1, 0, 0], [0, 0, 0], [0, 1, 0], [0, 0, 0], [0, 0, 1]])
+ .astype(np.float32)
+ .T
+ )
+ frame_axis_colors = (
+ np.array([[1, 0, 0], [1, 0.6, 0], [0, 1, 0], [0.6, 1, 0], [0, 0, 1], [0, 0.6, 1]])
+ .astype(np.float32)
+ .T
+ )
+ axis_length = 0.1
+ axis_width = 10
+ for frame_viz_name in frame_viz_names:
+ self.vis.viewer[frame_viz_name].set_object(
+ mg.LineSegments(
+ mg.PointsGeometry(
+ position=axis_length * frame_axis_positions,
+ color=frame_axis_colors,
+ ),
+ mg.LineBasicMaterial(
+ linewidth=axis_width,
+ vertexColors=True,
+ ),
+ )
+ )
+
+ def solve_ik(self, wrist, current_lr_arm_motor_q=None, current_lr_arm_motor_dq=None):
+ if current_lr_arm_motor_q is not None:
+ self.init_data = current_lr_arm_motor_q
+ self.opti.set_initial(self.var_q, self.init_data)
+
+ # left_wrist, right_wrist = self.scale_arms(left_wrist, right_wrist)
+ if self.visualization:
+ self.vis.viewer["ee_target"].set_transform(wrist) # for visualization
+
+ self.opti.set_value(self.param_tf, wrist)
+ self.opti.set_value(self.var_q_last, self.init_data) # for smooth
+
+ try:
+ self.opti.solve()
+ # sol = self.opti.solve_limited()
+
+ sol_q = self.opti.value(self.var_q)
+ self.smooth_filter.add_data(sol_q)
+ sol_q = self.smooth_filter.filtered_data
+
+ v = current_lr_arm_motor_dq * 0.0 if current_lr_arm_motor_dq is not None else (sol_q - self.init_data) * 0.0
+ self.init_data = sol_q
+ sol_tauff = pin.rnea(
+ self.reduced_robot.model,
+ self.reduced_robot.data,
+ sol_q,
+ v,
+ np.zeros(self.reduced_robot.model.nv),
+ )
+
+ if self.visualization:
+ self.vis.display(sol_q) # for visualization
+
+ return sol_q, sol_tauff
+
+ except Exception as e:
+ print(f"ERROR in convergence, plotting debug info.{e}")
+
+ sol_q = self.opti.debug.value(self.var_q)
+ self.smooth_filter.add_data(sol_q)
+ sol_q = self.smooth_filter.filtered_data
+
+ v = current_lr_arm_motor_dq * 0.0 if current_lr_arm_motor_dq is not None else (sol_q - self.init_data) * 0.0
+
+ self.init_data = sol_q
+ sol_tauff = pin.rnea(
+ self.reduced_robot.model,
+ self.reduced_robot.data,
+ sol_q,
+ v,
+ np.zeros(self.reduced_robot.model.nv),
+ )
+ if self.visualization:
+ self.vis.display(sol_q) # for visualization
+
+ # return sol_q, sol_tauff
+ return current_lr_arm_motor_q, np.zeros(self.reduced_robot.model.nv)
+
+
+if __name__ == "__main__":
+ arm_ik = Z1_Arm_IK(unit_test=True, visualization=True)
+
+ # initial positon
+ L_tf_target = pin.SE3(
+ pin.Quaternion(1, 0, 0, 0),
+ np.array([0.25, 0, 0.2]),
+ )
+
+ rotation_speed = 0.02
+ noise_amplitude_translation = 0.002
+ noise_amplitude_rotation = 0.1
+
+ user_input = input("Please enter the start signal (enter 's' to start the subsequent program):\n")
+ if user_input.lower() == "s":
+ step = 0
+ while True:
+ # Apply rotation noise with bias towards y and z axes
+ rotation_noise_l = pin.Quaternion(
+ np.cos(np.random.normal(0, noise_amplitude_rotation) / 2),
+ 0,
+ np.random.normal(0, noise_amplitude_rotation / 2),
+ 0,
+ ).normalized() # y bias
+
+ if step <= 120:
+ angle = rotation_speed * step
+ L_tf_target.rotation = (
+ rotation_noise_l * pin.Quaternion(np.cos(angle / 2), 0, np.sin(angle / 2), 0)
+ ).toRotationMatrix() # y axis
+ L_tf_target.translation += np.array([0.001, 0.001, 0.001]) + np.random.normal(
+ 0, noise_amplitude_translation, 3
+ )
+ else:
+ angle = rotation_speed * (240 - step)
+ L_tf_target.rotation = (
+ rotation_noise_l * pin.Quaternion(np.cos(angle / 2), 0, np.sin(angle / 2), 0)
+ ).toRotationMatrix() # y axis
+ L_tf_target.translation -= np.array([0.001, 0.001, 0.001]) + np.random.normal(
+ 0, noise_amplitude_translation, 3
+ )
+
+ sol_q, _ = arm_ik.solve_ik(L_tf_target.homogeneous)
+ step += 1
+ if step > 240:
+ step = 0
+ time.sleep(0.01)
diff --git a/unitree_deploy/unitree_deploy/robot_devices/arm/z1_dual_arm.py b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_dual_arm.py
new file mode 100644
index 0000000..f7afa78
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/arm/z1_dual_arm.py
@@ -0,0 +1,347 @@
+import os
+import sys
+import threading
+import time
+from typing import Callable
+
+import numpy as np
+
+sys.path.append(os.path.join(os.path.dirname(os.path.abspath(__file__))))
+import unitree_arm_interface as unitree_z1 # type: ignore
+
+from unitree_deploy.robot_devices.arm.arm_indexs import Z1_12_JointArmIndex
+from unitree_deploy.robot_devices.arm.configs import Z1DualArmConfig
+from unitree_deploy.robot_devices.arm.z1_arm_ik import Z1_Arm_IK
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ DataBuffer,
+ MotorState,
+ Robot_Num_Motors,
+ RobotDeviceAlreadyConnectedError,
+)
+from unitree_deploy.utils.joint_trajcetory_inter import JointTrajectoryInterpolator
+from unitree_deploy.utils.rich_logger import log_error, log_info, log_success, log_warning
+
+
+class Z1_12_LowState:
+ def __init__(self):
+ self.motor_state = [MotorState() for _ in range(Robot_Num_Motors.Z1_12_Num_Motors)]
+
+
+class Z1_12_ArmController:
+ def __init__(self, config: Z1DualArmConfig):
+ log_info("Initialize Z1_12_ArmController...")
+
+ self.left_robot_ip = config.left_robot_ip
+ self.left_robot_port1 = config.left_robot_port1
+ self.left_robot_port2 = config.left_robot_port2
+ self.right_robot_ip = config.right_robot_ip
+ self.right_robot_port1 = config.right_robot_port1
+ self.right_robot_port2 = config.right_robot_port2
+
+ self.robot_kp = config.robot_kp
+ self.robot_kd = config.robot_kd
+
+ self.max_pos_speed = config.max_pos_speed
+ self.init_pose_left = np.array(config.init_pose_left)
+ self.init_pose_right = np.array(config.init_pose_right)
+ self.init_pose = np.concatenate((self.init_pose_left, self.init_pose_right), axis=0)
+ self.unit_test = config.unit_test
+ self.control_dt = config.control_dt
+ self.motors = config.motors
+ self.mock = config.mock
+
+ self.q_target = np.concatenate((self.init_pose_left, self.init_pose_right))
+ self.tauff_target = np.zeros(len(Z1_12_JointArmIndex), dtype=np.float64)
+ self.dq_target = np.zeros(len(Z1_12_JointArmIndex) // 2, dtype=np.float64)
+ self.ddq_target = np.zeros(len(Z1_12_JointArmIndex) // 2, dtype=np.float64)
+ self.ftip_target = np.zeros(len(Z1_12_JointArmIndex) // 2, dtype=np.float64)
+ self.time_target = time.monotonic()
+ self.arm_cmd = "schedule_waypoint"
+
+ self.ctrl_lock = threading.Lock()
+ self.lowstate_buffer = DataBuffer()
+ self.stop_event = threading.Event()
+
+ self.z1_left_arm_ik = Z1_Arm_IK(unit_test=self.unit_test, visualization=False)
+ self.z1_right_arm_ik = Z1_Arm_IK(unit_test=self.unit_test, visualization=False)
+
+ self.arm_indices_len = len(Z1_12_JointArmIndex) // 2
+
+ self.is_connected = False
+
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+
+ def _start_daemon_thread(self, target_fn: Callable[[], None], name: str | None = None) -> threading.Thread:
+ thread = threading.Thread(target=target_fn, name=name)
+ thread.daemon = True
+ thread.start()
+ return thread
+
+ def initialize_arm(self, ip: str, port1: int, port2: int, name: str):
+ """Initialize z1."""
+ arm = unitree_z1.ArmInterface(ip, port1, port2)
+ arm_model = arm._ctrlComp.armModel
+ arm.setFsmLowcmd()
+ return arm, arm_model
+
+ def set_control_gains(self, kp, kd):
+ """Initialize kp kd."""
+ for arm in [self.z1_left, self.z1_right]:
+ arm.lowcmd.setControlGain(kp, kd)
+ arm.sendRecv()
+
+ def connect(self):
+ try:
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(
+ "Z1_Dual_Arm is already connected. Do not run `robot.connect()` twice."
+ )
+ # Initialize arms
+ self.z1_left, self.z1_left_model = self.initialize_arm(
+ self.left_robot_ip, self.left_robot_port1, self.left_robot_port2, "left"
+ )
+ self.z1_right, self.z1_right_model = self.initialize_arm(
+ self.right_robot_ip, self.right_robot_port1, self.right_robot_port2, "right"
+ )
+
+ # Set control gains
+ self.set_control_gains(self.robot_kp, self.robot_kd)
+
+ # initialize subscribe thread
+ self.subscribe_thread = self._start_daemon_thread(self._subscribe_motor_state, name="z1._subscribe_motor_state")
+
+ while not self.lowstate_buffer.get_data():
+ time.sleep(0.01)
+ log_warning("[Z1_12_ArmController] Waiting Get Data...")
+
+ self.publish_thread = self._start_daemon_thread(self._ctrl_motor_state, name="z1_dual._ctrl_motor_state")
+ self.is_connected = True
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in Z1_12_ArmController.connect: {e}")
+
+ def _subscribe_motor_state(self):
+ while True:
+ msg = {
+ "q": np.concatenate([self.z1_left.lowstate.getQ(), self.z1_right.lowstate.getQ()], axis=0),
+ "dq": np.concatenate([self.z1_left.lowstate.getQd(), self.z1_right.lowstate.getQd()], axis=0),
+ }
+ if msg is not None:
+ lowstate = Z1_12_LowState()
+ for id in range(Robot_Num_Motors.Z1_12_Num_Motors):
+ lowstate.motor_state[id].q = msg["q"][id]
+ lowstate.motor_state[id].dq = msg["dq"][id]
+ self.lowstate_buffer.set_data(lowstate)
+ time.sleep(self.control_dt)
+
+ def _update_z1_arm(
+ self,
+ arm,
+ arm_model,
+ q: np.ndarray,
+ qd: np.ndarray | None = None,
+ qdd: np.ndarray | None = None,
+ ftip: np.ndarray | None = None,
+ tau: np.ndarray | None = None,
+ ):
+ """Update the state and command of a given robotic arm."""
+ arm.q = q
+ arm.qd = self.dq_target if qd is None else qd
+ qdd = self.ddq_target if qdd is None else qdd
+ ftip = self.ftip_target if ftip is None else ftip
+ arm.tau = arm_model.inverseDynamics(arm.q, arm.qd, qdd, ftip) if tau is None else tau
+ arm.setArmCmd(arm.q, arm.qd, arm.tau)
+ arm.sendRecv()
+
+ def _drive_to_waypoint(self, target_pose: np.ndarray, t_insert_time: float):
+ curr_time = time.monotonic() + self.control_dt
+ t_insert = curr_time + t_insert_time
+ self.pose_interp = self.pose_interp.drive_to_waypoint(
+ pose=target_pose,
+ time=t_insert,
+ curr_time=curr_time,
+ max_pos_speed=self.max_pos_speed,
+ )
+
+ while time.monotonic() < t_insert:
+ self._update_z1_arm(
+ self.z1_left, self.z1_left_model, self.pose_interp(time.monotonic())[: self.arm_indices_len]
+ )
+ self._update_z1_arm(
+ self.z1_right, self.z1_right_model, self.pose_interp(time.monotonic())[self.arm_indices_len :]
+ )
+ time.sleep(self.control_dt)
+
+ def _schedule_waypoint(
+ self,
+ arm_q_target: np.ndarray,
+ arm_time_target: float,
+ t_now: float,
+ start_time: float,
+ last_waypoint_time: float,
+ arm_tauff_target: np.ndarray | None = None,
+ ) -> float:
+ target_time = time.monotonic() - time.perf_counter() + arm_time_target
+ curr_time = t_now + self.control_dt
+ target_time = max(target_time, curr_time + self.control_dt)
+
+ self.pose_interp = self.pose_interp.schedule_waypoint(
+ pose=arm_q_target,
+ time=target_time,
+ max_pos_speed=self.max_pos_speed,
+ curr_time=curr_time,
+ last_waypoint_time=last_waypoint_time,
+ )
+ last_waypoint_time = target_time
+ self._update_z1_arm(
+ arm=self.z1_left,
+ arm_model=self.z1_left_model,
+ q=self.pose_interp(t_now)[: self.arm_indices_len],
+ tau=arm_tauff_target[: self.arm_indices_len] if arm_tauff_target is not None else arm_tauff_target,
+ )
+ self._update_z1_arm(
+ arm=self.z1_right,
+ arm_model=self.z1_right_model,
+ q=self.pose_interp(t_now)[self.arm_indices_len :],
+ tau=arm_tauff_target[self.arm_indices_len :] if arm_tauff_target is not None else arm_tauff_target,
+ )
+
+ time.sleep(max(0, self.control_dt - (time.perf_counter() - start_time)))
+
+ def _ctrl_motor_state(self):
+ try:
+ self.pose_interp = JointTrajectoryInterpolator(
+ times=[time.monotonic()],
+ joint_positions=[self.read_current_arm_q()],
+ )
+
+ self.go_start()
+
+ arm_q_target = self.read_current_arm_q()
+ arm_tauff_target = self.tauff_target
+ arm_time_target = time.monotonic()
+ arm_cmd = "schedule_waypoint"
+
+ last_waypoint_time = time.monotonic()
+
+ while True:
+ start_time = time.perf_counter()
+ t_now = time.monotonic()
+
+ with self.ctrl_lock:
+ arm_q_target = self.q_target
+ arm_tauff_target = self.tauff_target
+ arm_time_target = self.time_target
+ arm_cmd = self.arm_cmd
+
+ if arm_cmd == "drive_to_waypoint":
+ self._drive_to_waypoint(target_pose=arm_q_target, t_insert_time=0.8)
+
+ elif arm_cmd == "schedule_waypoint":
+ self._schedule_waypoint(
+ arm_q_target=arm_q_target,
+ arm_time_target=arm_time_target,
+ t_now=t_now,
+ start_time=start_time,
+ last_waypoint_time=last_waypoint_time,
+ arm_tauff_target=arm_tauff_target,
+ )
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in Z1ArmController._ctrl_motor_state: {e}")
+
+ def write_arm(
+ self,
+ q_target: list[float] | np.ndarray,
+ tauff_target: list[float] | np.ndarray = None,
+ time_target: float | None = None,
+ cmd_target: str | None = None,
+ ):
+ """Set control target values q & tau of the left and right arm motors."""
+ with self.ctrl_lock:
+ self.q_target = q_target
+ self.tauff_target = tauff_target
+ self.time_target = time_target
+ self.arm_cmd = cmd_target
+
+ def read_current_arm_q(self) -> np.ndarray | None:
+ """Return current state q of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in Z1_12_JointArmIndex])
+
+ def read_current_arm_dq(self) -> np.ndarray | None:
+ """Return current state dq of the left and right arm motors."""
+ return np.array([self.lowstate_buffer.get_data().motor_state[id].dq for id in Z1_12_JointArmIndex])
+
+ def arm_ik(self, l_tf_target, r_tf_target) -> np.ndarray | None:
+ current_lr_arm_q = self.read_current_arm_q()
+ current_lr_arm_dq = self.read_current_arm_dq()
+
+ left_sol_q, left_sol_tauff = self.z1_left_arm_ik.solve_ik(
+ l_tf_target,
+ current_lr_arm_q[: self.arm_indices_len],
+ current_lr_arm_dq[: self.arm_indices_len],
+ )
+ right_sol_q, right_sol_tauff = self.z1_right_arm_ik.solve_ik(
+ r_tf_target,
+ current_lr_arm_q[self.arm_indices_len :],
+ current_lr_arm_dq[self.arm_indices_len :],
+ )
+
+ sol_q = np.concatenate([left_sol_q, right_sol_q], axis=0)
+ sol_tauff = np.concatenate([left_sol_tauff, right_sol_tauff], axis=0)
+
+ return sol_q, sol_tauff
+
+ def arm_fk(self, left_q: np.ndarray | None = None, right_q: np.ndarray | None = None) -> np.ndarray | None:
+ left = self.z1_left_arm_ik.solve_fk(
+ left_q if left_q is not None else self.read_current_arm_q()[: self.arm_indices_len]
+ )
+ right = self.z1_right_arm_ik.solve_fk(
+ right_q if right_q is not None else self.read_current_arm_q()[self.arm_indices_len :]
+ )
+
+ return left, right
+
+ def go_start(self):
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=1.0)
+ log_success("Go Start OK!\n")
+
+ def go_home(self):
+ if self.mock:
+ self.stop_event.set()
+ # self.subscribe_thread.join()
+ # self.publish_thread.join()
+ time.sleep(1)
+ else:
+ self.is_connected = False
+
+ self.z1_right.loopOn()
+ self.z1_right.backToStart()
+ self.z1_right.loopOff()
+
+ self.z1_left.loopOn()
+ self.z1_left.backToStart()
+ self.z1_left.loopOff()
+
+ time.sleep(0.5)
+ log_success("Go Home OK!\n")
+
+ def disconnect(self):
+ self.is_connected = False
+ self.go_home()
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/.gitignore b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/.gitignore
new file mode 100644
index 0000000..0a293e4
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/.gitignore
@@ -0,0 +1,2 @@
+*.gv
+*.pdf
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/README.md b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/README.md
new file mode 100644
index 0000000..f666cf9
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/README.md
@@ -0,0 +1,33 @@
+# Unitree G1 Description (URDF & MJCF)
+
+## Overview
+
+This package includes a universal humanoid robot description (URDF & MJCF) for the [Unitree G1](https://www.unitree.com/g1/), developed by [Unitree Robotics](https://www.unitree.com/).
+
+MJCF/URDF for the G1 robot:
+
+| MJCF/URDF file name | `mode_machine` | Hip roll reduction ratio | Update status | dof#leg | dof#waist | dof#arm | dof#hand |
+| ----------------------------- | :------------: | :----------------------: | ------------- | :-----: | :-------: | :-----: | :------: |
+| `g1_23dof` | 1 | 14.5 | Beta | 6*2 | 1 | 5*2 | 0 |
+| `g1_29dof` | 2 | 14.5 | Beta | 6*2 | 3 | 7*2 | 0 |
+| `g1_29dof_with_hand` | 2 | 14.5 | Beta | 6*2 | 3 | 7*2 | 7*2 |
+| `g1_29dof_lock_waist` | 3 | 14.5 | Beta | 6*2 | 1 | 7*2 | 0 |
+| `g1_23dof_rev_1_0` | 4 | 22.5 | Up-to-date | 6*2 | 1 | 5*2 | 0 |
+| `g1_29dof_rev_1_0` | 5 | 22.5 | Up-to-date | 6*2 | 3 | 7*2 | 0 |
+| `g1_29dof_with_hand_rev_1_0` | 5 | 22.5 | Up-to-date | 6*2 | 3 | 7*2 | 7*2 |
+| `g1_29dof_lock_waist_rev_1_0` | 6 | 22.5 | Up-to-date | 6*2 | 1 | 7*2 | 0 |
+| `g1_dual_arm` | 9 | null | Up-to-date | 0 | 0 | 7*2 | 0 |
+
+## Visulization with [MuJoCo](https://github.com/google-deepmind/mujoco)
+
+1. Open MuJoCo Viewer
+
+ ```bash
+ pip install mujoco
+ python -m mujoco.viewer
+ ```
+
+2. Drag and drop the MJCF/URDF model file (`g1_XXX.xml`/`g1_XXX.urdf`) to the MuJoCo Viewer.
+
+## Note for teleoperate
+g1_body29_hand14 is modified from [g1_29dof_with_hand_rev_1_0](https://github.com/unitreerobotics/unitree_ros/blob/master/robots/g1_description/g1_29dof_with_hand_rev_1_0.urdf)
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29.xml b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29.xml
new file mode 100644
index 0000000..93214dd
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29.xml
@@ -0,0 +1,381 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.urdf b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.urdf
new file mode 100644
index 0000000..156a5fd
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.urdf
@@ -0,0 +1,1476 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.xml b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.xml
new file mode 100644
index 0000000..01fd5e2
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/g1_body29_hand14.xml
@@ -0,0 +1,408 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/head_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/head_link.STL
new file mode 100644
index 0000000..2ee5fba
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/head_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_pitch_link.STL
new file mode 100644
index 0000000..69de849
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_roll_link.STL
new file mode 100644
index 0000000..8864e9f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_ankle_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_elbow_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_elbow_link.STL
new file mode 100644
index 0000000..1a96d99
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_elbow_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_0_link.STL
new file mode 100644
index 0000000..8069369
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_1_link.STL
new file mode 100644
index 0000000..89d231d
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_index_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_0_link.STL
new file mode 100644
index 0000000..8069369
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_1_link.STL
new file mode 100644
index 0000000..89d231d
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_middle_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_palm_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_palm_link.STL
new file mode 100644
index 0000000..7d595ed
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_palm_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_0_link.STL
new file mode 100644
index 0000000..3028bb4
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_1_link.STL
new file mode 100644
index 0000000..d1c080c
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_2_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_2_link.STL
new file mode 100644
index 0000000..8b32e96
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hand_thumb_2_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_pitch_link.STL
new file mode 100644
index 0000000..5b751c7
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_roll_link.STL
new file mode 100644
index 0000000..778437f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_yaw_link.STL
new file mode 100644
index 0000000..383093a
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_hip_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_knee_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_knee_link.STL
new file mode 100644
index 0000000..f2e98e5
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_knee_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_rubber_hand.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_rubber_hand.STL
new file mode 100644
index 0000000..c44830f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_rubber_hand.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_pitch_link.STL
new file mode 100644
index 0000000..e698311
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_roll_link.STL
new file mode 100644
index 0000000..80bca84
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_yaw_link.STL
new file mode 100644
index 0000000..281e699
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_shoulder_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_pitch_link.STL
new file mode 100644
index 0000000..82cc224
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_link.STL
new file mode 100644
index 0000000..f3c263a
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_rubber_hand.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_rubber_hand.STL
new file mode 100644
index 0000000..8fa435b
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_roll_rubber_hand.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_yaw_link.STL
new file mode 100644
index 0000000..31be4fd
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/left_wrist_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/logo_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/logo_link.STL
new file mode 100644
index 0000000..e979209
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/logo_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis.STL
new file mode 100644
index 0000000..691a779
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis_contour_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis_contour_link.STL
new file mode 100644
index 0000000..4243433
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/pelvis_contour_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_pitch_link.STL
new file mode 100644
index 0000000..e77d8a2
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_roll_link.STL
new file mode 100644
index 0000000..d4261dd
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_ankle_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_elbow_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_elbow_link.STL
new file mode 100644
index 0000000..f259e38
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_elbow_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_0_link.STL
new file mode 100644
index 0000000..f87ad32
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_1_link.STL
new file mode 100644
index 0000000..6dea51a
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_index_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_0_link.STL
new file mode 100644
index 0000000..f87ad32
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_1_link.STL
new file mode 100644
index 0000000..6dea51a
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_middle_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_palm_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_palm_link.STL
new file mode 100644
index 0000000..5ae00a7
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_palm_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_0_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_0_link.STL
new file mode 100644
index 0000000..1cae7f1
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_0_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_1_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_1_link.STL
new file mode 100644
index 0000000..c141fbf
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_1_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_2_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_2_link.STL
new file mode 100644
index 0000000..e942923
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hand_thumb_2_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_pitch_link.STL
new file mode 100644
index 0000000..998a0a0
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_roll_link.STL
new file mode 100644
index 0000000..47b2eeb
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_yaw_link.STL
new file mode 100644
index 0000000..3718564
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_hip_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_knee_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_knee_link.STL
new file mode 100644
index 0000000..76d21a3
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_knee_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_rubber_hand.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_rubber_hand.STL
new file mode 100644
index 0000000..0aacffb
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_rubber_hand.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_pitch_link.STL
new file mode 100644
index 0000000..3f5b4ed
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_roll_link.STL
new file mode 100644
index 0000000..179d617
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_yaw_link.STL
new file mode 100644
index 0000000..2ba6076
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_shoulder_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_pitch_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_pitch_link.STL
new file mode 100644
index 0000000..da19454
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_pitch_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_link.STL
new file mode 100644
index 0000000..26868d2
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_rubber_hand.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_rubber_hand.STL
new file mode 100644
index 0000000..c365aa9
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_roll_rubber_hand.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_yaw_link.STL
new file mode 100644
index 0000000..d788902
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/right_wrist_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_link.STL
new file mode 100644
index 0000000..75d82f5
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_rod_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_rod_link.STL
new file mode 100644
index 0000000..6747f3f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_L_rod_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_link.STL
new file mode 100644
index 0000000..5cb5958
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_rod_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_rod_link.STL
new file mode 100644
index 0000000..95cf415
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_constraint_R_rod_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link.STL
new file mode 100644
index 0000000..17745af
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_23dof_rev_1_0.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_23dof_rev_1_0.STL
new file mode 100644
index 0000000..079aa81
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_23dof_rev_1_0.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_rev_1_0.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_rev_1_0.STL
new file mode 100644
index 0000000..8a759a7
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/torso_link_rev_1_0.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_L.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_L.STL
new file mode 100644
index 0000000..911410f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_L.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_R.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_R.STL
new file mode 100644
index 0000000..babe794
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_constraint_R.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link.STL
new file mode 100644
index 0000000..65831ab
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link_rev_1_0.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link_rev_1_0.STL
new file mode 100644
index 0000000..a64f330
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_roll_link_rev_1_0.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_support_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_support_link.STL
new file mode 100644
index 0000000..63660fb
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_support_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link.STL
new file mode 100644
index 0000000..7d36b02
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link_rev_1_0.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link_rev_1_0.STL
new file mode 100644
index 0000000..0fabb63
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/g1/meshes/waist_yaw_link_rev_1_0.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperMover.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperMover.STL
new file mode 100644
index 0000000..7e9d78c
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperMover.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperStator.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperStator.STL
new file mode 100644
index 0000000..762062f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_GripperStator.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link00.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link00.STL
new file mode 100644
index 0000000..2582762
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link00.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link01.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link01.STL
new file mode 100644
index 0000000..951349f
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link01.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link02.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link02.STL
new file mode 100644
index 0000000..f14f26e
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link02.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link03.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link03.STL
new file mode 100644
index 0000000..ec6b612
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link03.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link04.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link04.STL
new file mode 100644
index 0000000..36241d2
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link04.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link05.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link05.STL
new file mode 100644
index 0000000..7402293
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link05.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link06.STL b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link06.STL
new file mode 100644
index 0000000..efa007b
Binary files /dev/null and b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/meshes/z1_Link06.STL differ
diff --git a/unitree_deploy/unitree_deploy/robot_devices/assets/z1/z1.urdf b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/z1.urdf
new file mode 100644
index 0000000..a9baebe
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/assets/z1/z1.urdf
@@ -0,0 +1,261 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+ transmission_interface/SimpleTransmission
+
+ hardware_interface/EffortJointInterface
+
+
+ hardware_interface/EffortJointInterface
+ 1
+
+
+
+
diff --git a/unitree_deploy/unitree_deploy/robot_devices/cameras/configs.py b/unitree_deploy/unitree_deploy/robot_devices/cameras/configs.py
new file mode 100644
index 0000000..97750ed
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/cameras/configs.py
@@ -0,0 +1,125 @@
+"""
+@misc{cadene2024lerobot,
+ author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and Gallouedec, Quentin and Zouitine, Adil and Wolf, Thomas},
+ title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in PyTorch},
+ howpublished = {Available at: https://github.com/huggingface/lerobot},
+ year = {2024},
+}
+"""
+
+import abc
+from dataclasses import dataclass
+
+import draccus
+
+
+@dataclass
+class CameraConfig(draccus.ChoiceRegistry, abc.ABC):
+ @property
+ def type(self) -> str:
+ return self.get_choice_name(self.__class__)
+
+
+@CameraConfig.register_subclass("opencv")
+@dataclass
+class OpenCVCameraConfig(CameraConfig):
+ """
+ Example of tested options for Intel Real Sense D405:
+
+ ```python
+ OpenCVCameraConfig(0, 30, 640, 480)
+ OpenCVCameraConfig(0, 60, 640, 480)
+ OpenCVCameraConfig(0, 90, 640, 480)
+ OpenCVCameraConfig(0, 30, 1280, 720)
+ ```
+ """
+
+ camera_index: int
+ fps: int | None = None
+ width: int | None = None
+ height: int | None = None
+ color_mode: str = "rgb"
+ channels: int | None = None
+ rotation: int | None = None
+ mock: bool = False
+
+ def __post_init__(self):
+ if self.color_mode not in ["rgb", "bgr"]:
+ raise ValueError(
+ f"`color_mode` is expected to be 'rgb' or 'bgr', but {self.color_mode} is provided."
+ )
+
+ self.channels = 3
+
+ if self.rotation not in [-90, None, 90, 180]:
+ raise ValueError(f"`rotation` must be in [-90, None, 90, 180] (got {self.rotation})")
+
+
+@CameraConfig.register_subclass("intelrealsense")
+@dataclass
+class IntelRealSenseCameraConfig(CameraConfig):
+ """
+ Example of tested options for Intel Real Sense D405:
+
+ ```python
+ IntelRealSenseCameraConfig(128422271347, 30, 640, 480)
+ IntelRealSenseCameraConfig(128422271347, 60, 640, 480)
+ IntelRealSenseCameraConfig(128422271347, 90, 640, 480)
+ IntelRealSenseCameraConfig(128422271347, 30, 1280, 720)
+ IntelRealSenseCameraConfig(128422271347, 30, 640, 480, use_depth=True)
+ IntelRealSenseCameraConfig(128422271347, 30, 640, 480, rotation=90)
+ ```
+ """
+
+ name: str | None = None
+ serial_number: int | None = None
+ fps: int | None = None
+ width: int | None = None
+ height: int | None = None
+ color_mode: str = "rgb"
+ channels: int | None = None
+ use_depth: bool = False
+ force_hardware_reset: bool = True
+ rotation: int | None = None
+ mock: bool = False
+
+ def __post_init__(self):
+ # bool is stronger than is None, since it works with empty strings
+ if bool(self.name) and bool(self.serial_number):
+ raise ValueError(
+ f"One of them must be set: name or serial_number, but {self.name=} and {self.serial_number=} provided."
+ )
+
+ if self.color_mode not in ["rgb", "bgr"]:
+ raise ValueError(
+ f"`color_mode` is expected to be 'rgb' or 'bgr', but {self.color_mode} is provided."
+ )
+
+ self.channels = 3
+
+ at_least_one_is_not_none = self.fps is not None or self.width is not None or self.height is not None
+ at_least_one_is_none = self.fps is None or self.width is None or self.height is None
+ if at_least_one_is_not_none and at_least_one_is_none:
+ raise ValueError(
+ "For `fps`, `width` and `height`, either all of them need to be set, or none of them, "
+ f"but {self.fps=}, {self.width=}, {self.height=} were provided."
+ )
+
+ if self.rotation not in [-90, None, 90, 180]:
+ raise ValueError(f"`rotation` must be in [-90, None, 90, 180] (got {self.rotation})")
+
+
+@CameraConfig.register_subclass("imageclient")
+@dataclass
+class ImageClientCameraConfig(CameraConfig):
+ head_camera_type: str
+ head_camera_id_numbers: list[int]
+ head_camera_image_shape: list[int]
+
+ wrist_camera_type: str | None = None
+ wrist_camera_id_numbers: list[int] | None = None
+ wrist_camera_image_shape: list[int] | None = None
+
+ aspect_ratio_threshold: float = 2.0
+ fps: int = 30
+ mock: bool = False
diff --git a/unitree_deploy/unitree_deploy/robot_devices/cameras/imageclient.py b/unitree_deploy/unitree_deploy/robot_devices/cameras/imageclient.py
new file mode 100644
index 0000000..76f31a9
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/cameras/imageclient.py
@@ -0,0 +1,312 @@
+"""
+This file contains utilities for recording frames from cameras. For more info look at `OpenCVCamera` docstring.
+"""
+
+import struct
+import threading
+import time
+from collections import deque
+from multiprocessing import shared_memory
+
+import cv2
+import numpy as np
+import zmq
+
+from unitree_deploy.robot_devices.cameras.configs import ImageClientCameraConfig
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ RobotDeviceAlreadyConnectedError,
+ RobotDeviceNotConnectedError,
+)
+from unitree_deploy.utils.rich_logger import log_error, log_info, log_success, log_warning
+
+
+class ImageClient:
+ def __init__(
+ self,
+ tv_img_shape=None,
+ tv_img_shm_name=None,
+ wrist_img_shape=None,
+ wrist_img_shm_name=None,
+ image_show=False,
+ server_address="192.168.123.164",
+ port=5555,
+ unit_test=False,
+ ):
+ """
+ tv_img_shape: User's expected head camera resolution shape (H, W, C). It should match the output of the image service terminal.
+ tv_img_shm_name: Shared memory is used to easily transfer images across processes to the Vuer.
+ wrist_img_shape: User's expected wrist camera resolution shape (H, W, C). It should maintain the same shape as tv_img_shape.
+ wrist_img_shm_name: Shared memory is used to easily transfer images.
+ image_show: Whether to display received images in real time.
+ server_address: The ip address to execute the image server script.
+ port: The port number to bind to. It should be the same as the image server.
+ Unit_Test: When both server and client are True, it can be used to test the image transfer latency, \
+ network jitter, frame loss rate and other information.
+ """
+ self.running = True
+ self._image_show = image_show
+ self._server_address = server_address
+ self._port = port
+
+ self.tv_img_shape = tv_img_shape
+ self.wrist_img_shape = wrist_img_shape
+
+ self.tv_enable_shm = False
+ if self.tv_img_shape is not None and tv_img_shm_name is not None:
+ self.tv_image_shm = shared_memory.SharedMemory(name=tv_img_shm_name)
+ self.tv_img_array = np.ndarray(tv_img_shape, dtype=np.uint8, buffer=self.tv_image_shm.buf)
+ self.tv_enable_shm = True
+
+ self.wrist_enable_shm = False
+ if self.wrist_img_shape is not None and wrist_img_shm_name is not None:
+ self.wrist_image_shm = shared_memory.SharedMemory(name=wrist_img_shm_name)
+ self.wrist_img_array = np.ndarray(wrist_img_shape, dtype=np.uint8, buffer=self.wrist_image_shm.buf)
+ self.wrist_enable_shm = True
+
+ # Performance evaluation parameters
+ self._enable_performance_eval = unit_test
+ if self._enable_performance_eval:
+ self._init_performance_metrics()
+
+ def _init_performance_metrics(self):
+ self._frame_count = 0 # Total frames received
+ self._last_frame_id = -1 # Last received frame ID
+
+ # Real-time FPS calculation using a time window
+ self._time_window = 1.0 # Time window size (in seconds)
+ self._frame_times = deque() # Timestamps of frames received within the time window
+
+ # Data transmission quality metrics
+ self._latencies = deque() # Latencies of frames within the time window
+ self._lost_frames = 0 # Total lost frames
+ self._total_frames = 0 # Expected total frames based on frame IDs
+
+ def _update_performance_metrics(self, timestamp, frame_id, receive_time):
+ # Update latency
+ latency = receive_time - timestamp
+ self._latencies.append(latency)
+
+ # Remove latencies outside the time window
+ while self._latencies and self._frame_times and self._latencies[0] < receive_time - self._time_window:
+ self._latencies.popleft()
+
+ # Update frame times
+ self._frame_times.append(receive_time)
+ # Remove timestamps outside the time window
+ while self._frame_times and self._frame_times[0] < receive_time - self._time_window:
+ self._frame_times.popleft()
+
+ # Update frame counts for lost frame calculation
+ expected_frame_id = self._last_frame_id + 1 if self._last_frame_id != -1 else frame_id
+ if frame_id != expected_frame_id:
+ lost = frame_id - expected_frame_id
+ if lost < 0:
+ log_info(f"[Image Client] Received out-of-order frame ID: {frame_id}")
+ else:
+ self._lost_frames += lost
+ log_info(
+ f"[Image Client] Detected lost frames: {lost}, Expected frame ID: {expected_frame_id}, Received frame ID: {frame_id}"
+ )
+ self._last_frame_id = frame_id
+ self._total_frames = frame_id + 1
+
+ self._frame_count += 1
+
+ def _print_performance_metrics(self, receive_time):
+ if self._frame_count % 30 == 0:
+ # Calculate real-time FPS
+ real_time_fps = len(self._frame_times) / self._time_window if self._time_window > 0 else 0
+
+ # Calculate latency metrics
+ if self._latencies:
+ avg_latency = sum(self._latencies) / len(self._latencies)
+ max_latency = max(self._latencies)
+ min_latency = min(self._latencies)
+ jitter = max_latency - min_latency
+ else:
+ avg_latency = max_latency = min_latency = jitter = 0
+
+ # Calculate lost frame rate
+ lost_frame_rate = (self._lost_frames / self._total_frames) * 100 if self._total_frames > 0 else 0
+
+ log_info(
+ f"[Image Client] Real-time FPS: {real_time_fps:.2f}, Avg Latency: {avg_latency * 1000:.2f} ms, Max Latency: {max_latency * 1000:.2f} ms, \
+ Min Latency: {min_latency * 1000:.2f} ms, Jitter: {jitter * 1000:.2f} ms, Lost Frame Rate: {lost_frame_rate:.2f}%"
+ )
+
+ def _close(self):
+ self._socket.close()
+ self._context.term()
+ if self._image_show:
+ cv2.destroyAllWindows()
+ log_success("Image client has been closed.")
+
+ def receive_process(self):
+ # Set up ZeroMQ context and socket
+ self._context = zmq.Context()
+ self._socket = self._context.socket(zmq.SUB)
+ self._socket.connect(f"tcp://{self._server_address}:{self._port}")
+ self._socket.setsockopt_string(zmq.SUBSCRIBE, "")
+
+ log_warning("\nImage client has started, waiting to receive data...")
+ try:
+ while self.running:
+ # Receive message
+ message = self._socket.recv()
+ receive_time = time.time()
+
+ if self._enable_performance_eval:
+ header_size = struct.calcsize("dI")
+ try:
+ # Attempt to extract header and image data
+ header = message[:header_size]
+ jpg_bytes = message[header_size:]
+ timestamp, frame_id = struct.unpack("dI", header)
+ except struct.error as e:
+ log_error(f"[Image Client] Error unpacking header: {e}, discarding message.")
+ continue
+ else:
+ # No header, entire message is image data
+ jpg_bytes = message
+ # Decode image
+ np_img = np.frombuffer(jpg_bytes, dtype=np.uint8)
+ current_image = cv2.imdecode(np_img, cv2.IMREAD_COLOR)
+ if current_image is None:
+ log_error("[Image Client] Failed to decode image.")
+ continue
+
+ if self.tv_enable_shm:
+ np.copyto(self.tv_img_array, np.array(current_image[:, : self.tv_img_shape[1]]))
+
+ if self.wrist_enable_shm:
+ np.copyto(self.wrist_img_array, np.array(current_image[:, -self.wrist_img_shape[1] :]))
+
+ if self._image_show:
+ height, width = current_image.shape[:2]
+ resized_image = cv2.resize(current_image, (width // 2, height // 2))
+ cv2.imshow("Image Client Stream", resized_image)
+ if cv2.waitKey(1) & 0xFF == ord("q"):
+ self.running = False
+
+ if self._enable_performance_eval:
+ self._update_performance_metrics(timestamp, frame_id, receive_time)
+ self._print_performance_metrics(receive_time)
+
+ except KeyboardInterrupt:
+ log_error("Image client interrupted by user.")
+ except Exception as e:
+ log_error(f"[Image Client] An error occurred while receiving data: {e}")
+ finally:
+ self._close()
+
+
+class ImageClientCamera:
+ def __init__(self, config: ImageClientCameraConfig):
+ self.config = config
+ self.fps = config.fps
+ self.head_camera_type = config.head_camera_type
+ self.head_camera_image_shape = config.head_camera_image_shape
+ self.head_camera_id_numbers = config.head_camera_id_numbers
+ self.wrist_camera_type = config.wrist_camera_type
+ self.wrist_camera_image_shape = config.wrist_camera_image_shape
+ self.wrist_camera_id_numbers = config.wrist_camera_id_numbers
+ self.aspect_ratio_threshold = config.aspect_ratio_threshold
+ self.mock = config.mock
+
+ self.is_binocular = (
+ len(self.head_camera_id_numbers) > 1
+ or self.head_camera_image_shape[1] / self.head_camera_image_shape[0] > self.aspect_ratio_threshold
+ ) # self.is_binocular
+
+ self.has_wrist_camera = self.wrist_camera_type is not None # self.has_wrist_camera
+
+ self.tv_img_shape = (
+ (self.head_camera_image_shape[0], self.head_camera_image_shape[1] * 2, 3)
+ if self.is_binocular
+ and not (self.head_camera_image_shape[1] / self.head_camera_image_shape[0] > self.aspect_ratio_threshold)
+ else (self.head_camera_image_shape[0], self.head_camera_image_shape[1], 3)
+ )
+
+ self.tv_img_shm = shared_memory.SharedMemory(create=True, size=np.prod(self.tv_img_shape) * np.uint8().itemsize)
+ self.tv_img_array = np.ndarray(self.tv_img_shape, dtype=np.uint8, buffer=self.tv_img_shm.buf)
+ self.wrist_img_shape = None
+ self.wrist_img_shm = None
+
+ if self.has_wrist_camera:
+ self.wrist_img_shape = (self.wrist_camera_image_shape[0], self.wrist_camera_image_shape[1] * 2, 3)
+ self.wrist_img_shm = shared_memory.SharedMemory(
+ create=True, size=np.prod(self.wrist_img_shape) * np.uint8().itemsize
+ )
+ self.wrist_img_array = np.ndarray(self.wrist_img_shape, dtype=np.uint8, buffer=self.wrist_img_shm.buf)
+ self.img_shm_name = self.tv_img_shm.name
+ self.is_connected = False
+
+ def connect(self):
+ try:
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(f"ImageClient({self.camera_index}) is already connected.")
+
+ self.img_client = ImageClient(
+ tv_img_shape=self.tv_img_shape,
+ tv_img_shm_name=self.tv_img_shm.name,
+ wrist_img_shape=self.wrist_img_shape,
+ wrist_img_shm_name=self.wrist_img_shm.name if self.wrist_img_shm else None,
+ )
+
+ image_receive_thread = threading.Thread(target=self.img_client.receive_process, daemon=True)
+ image_receive_thread.daemon = True
+ image_receive_thread.start()
+
+ self.is_connected = True
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in ImageClientCamera.connect: {e}")
+
+ def read(self) -> np.ndarray:
+ pass
+
+ def async_read(self):
+ try:
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ "ImageClient is not connected. Try running `camera.connect()` first."
+ )
+ current_tv_image = self.tv_img_array.copy()
+ current_wrist_image = self.wrist_img_array.copy() if self.has_wrist_camera else None
+
+ colors = {}
+ if self.is_binocular:
+ colors["cam_left_high"] = current_tv_image[:, : self.tv_img_shape[1] // 2]
+ colors["cam_right_high"] = current_tv_image[:, self.tv_img_shape[1] // 2 :]
+ if self.has_wrist_camera:
+ colors["cam_left_wrist"] = current_wrist_image[:, : self.wrist_img_shape[1] // 2]
+ colors["cam_right_wrist"] = current_wrist_image[:, self.wrist_img_shape[1] // 2 :]
+ else:
+ colors["cam_high"] = current_tv_image
+ if self.has_wrist_camera:
+ colors["cam_left_wrist"] = current_wrist_image[:, : self.wrist_img_shape[1] // 2]
+ colors["cam_right_wrist"] = current_wrist_image[:, self.wrist_img_shape[1] // 2 :]
+
+ return colors
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in ImageClientCamera.async_read: {e}")
+
+ def disconnect(self):
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"ImageClient({self.camera_index}) is not connected. Try running `camera.connect()` first."
+ )
+
+ self.tv_img_shm.unlink()
+ self.tv_img_shm.close()
+ if self.has_wrist_camera:
+ self.wrist_img_shm.unlink()
+ self.wrist_img_shm.close()
+ self.is_connected = False
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
diff --git a/unitree_deploy/unitree_deploy/robot_devices/cameras/intelrealsense.py b/unitree_deploy/unitree_deploy/robot_devices/cameras/intelrealsense.py
new file mode 100644
index 0000000..b552902
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/cameras/intelrealsense.py
@@ -0,0 +1,504 @@
+"""
+@misc{cadene2024lerobot,
+ author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and Gallouedec, Quentin and Zouitine, Adil and Wolf, Thomas},
+ title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in PyTorch},
+ howpublished = {Available at: https://github.com/huggingface/lerobot},
+ year = {2024},
+}
+This file contains utilities for recording frames from Intel Realsense cameras.
+"""
+
+import argparse
+import concurrent.futures
+import logging
+import math
+import shutil
+import threading
+import time
+import traceback
+from collections import Counter
+from pathlib import Path
+from threading import Thread
+
+import cv2
+import numpy as np
+import pyrealsense2 as rs
+from PIL import Image
+
+from unitree_deploy.robot_devices.cameras.configs import IntelRealSenseCameraConfig
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ RobotDeviceAlreadyConnectedError,
+ RobotDeviceNotConnectedError,
+ busy_wait,
+ capture_timestamp_utc,
+)
+
+SERIAL_NUMBER_INDEX = 1
+
+
+def find_cameras(raise_when_empty=True, mock=False) -> list[dict]:
+ """
+ Find the names and the serial numbers of the Intel RealSense cameras
+ connected to the computer.
+ """
+ cameras = []
+ for device in rs.context().query_devices():
+ serial_number = device.get_info(rs.camera_info(SERIAL_NUMBER_INDEX))
+ name = device.get_info(rs.camera_info.name)
+ cameras.append(
+ {
+ "serial_number": serial_number,
+ "name": name,
+ }
+ )
+
+ if raise_when_empty and len(cameras) == 0:
+ raise OSError(
+ "Not a single camera was detected. Try re-plugging, or re-installing `librealsense` and its python wrapper `pyrealsense2`, or updating the firmware."
+ )
+
+ return cameras
+
+
+def save_image(img_array, serial_number, frame_index, images_dir):
+ try:
+ img = Image.fromarray(img_array)
+ path = images_dir / f"camera_{serial_number}_frame_{frame_index:06d}.png"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ img.save(str(path), quality=100)
+ logging.info(f"Saved image: {path}")
+ except Exception as e:
+ logging.error(f"Failed to save image for camera {serial_number} frame {frame_index}: {e}")
+
+
+def save_images_from_cameras(
+ images_dir: Path,
+ serial_numbers: list[int] | None = None,
+ fps=None,
+ width=None,
+ height=None,
+ record_time_s=2,
+ mock=False,
+):
+ """
+ Initializes all the cameras and saves images to the directory. Useful to visually identify the camera
+ associated to a given serial number.
+ """
+ if serial_numbers is None or len(serial_numbers) == 0:
+ camera_infos = find_cameras(mock=mock)
+ serial_numbers = [cam["serial_number"] for cam in camera_infos]
+
+ print("Connecting cameras")
+ cameras = []
+ for cam_sn in serial_numbers:
+ print(f"{cam_sn=}")
+ config = IntelRealSenseCameraConfig(
+ serial_number=cam_sn, fps=fps, width=width, height=height, mock=mock
+ )
+ camera = IntelRealSenseCamera(config)
+ camera.connect()
+ print(
+ f"IntelRealSenseCamera({camera.serial_number}, fps={camera.fps}, width={camera.width}, height={camera.height}, color_mode={camera.color_mode})"
+ )
+ cameras.append(camera)
+
+ images_dir = Path(images_dir)
+ if images_dir.exists():
+ shutil.rmtree(
+ images_dir,
+ )
+ images_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"Saving images to {images_dir}")
+ frame_index = 0
+ start_time = time.perf_counter()
+ try:
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
+ while True:
+ now = time.perf_counter()
+
+ for camera in cameras:
+ # If we use async_read when fps is None, the loop will go full speed, and we will end up
+ # saving the same images from the cameras multiple times until the RAM/disk is full.
+ image = camera.read() if fps is None else camera.async_read()
+ if image is None:
+ print("No Frame")
+
+ bgr_converted_image = cv2.cvtColor(image, cv2.COLOR_RGB2BGR)
+
+ executor.submit(
+ save_image,
+ bgr_converted_image,
+ camera.serial_number,
+ frame_index,
+ images_dir,
+ )
+
+ if fps is not None:
+ dt_s = time.perf_counter() - now
+ busy_wait(1 / fps - dt_s)
+
+ if time.perf_counter() - start_time > record_time_s:
+ break
+
+ print(f"Frame: {frame_index:04d}\tLatency (ms): {(time.perf_counter() - now) * 1000:.2f}")
+
+ frame_index += 1
+ finally:
+ print(f"Images have been saved to {images_dir}")
+ for camera in cameras:
+ camera.disconnect()
+
+
+class IntelRealSenseCamera:
+ """
+ The IntelRealSenseCamera class is similar to OpenCVCamera class but adds additional features for Intel Real Sense cameras:
+ - is instantiated with the serial number of the camera - won't randomly change as it can be the case of OpenCVCamera for Linux,
+ - can also be instantiated with the camera's name β if it's unique β using IntelRealSenseCamera.init_from_name(),
+ - depth map can be returned.
+
+ To find the camera indices of your cameras, you can run our utility script that will save a few frames for each camera:
+ ```bash
+ python lerobot/common/robot_devices/cameras/intelrealsense.py --images-dir outputs/images_from_intelrealsense_cameras
+ ```
+
+ When an IntelRealSenseCamera is instantiated, if no specific config is provided, the default fps, width, height and color_mode
+ of the given camera will be used.
+
+ Example of instantiating with a serial number:
+ ```python
+ from lerobot.common.robot_devices.cameras.configs import IntelRealSenseCameraConfig
+
+ config = IntelRealSenseCameraConfig(serial_number=128422271347)
+ camera = IntelRealSenseCamera(config)
+ camera.connect()
+ color_image = camera.read()
+ # when done using the camera, consider disconnecting
+ camera.disconnect()
+ ```
+
+ Example of instantiating with a name if it's unique:
+ ```
+ config = IntelRealSenseCameraConfig(name="Intel RealSense D405")
+ ```
+
+ Example of changing default fps, width, height and color_mode:
+ ```python
+ config = IntelRealSenseCameraConfig(serial_number=128422271347, fps=30, width=1280, height=720)
+ config = IntelRealSenseCameraConfig(serial_number=128422271347, fps=90, width=640, height=480)
+ config = IntelRealSenseCameraConfig(serial_number=128422271347, fps=90, width=640, height=480, color_mode="bgr")
+ # Note: might error out upon `camera.connect()` if these settings are not compatible with the camera
+ ```
+
+ Example of returning depth:
+ ```python
+ config = IntelRealSenseCameraConfig(serial_number=128422271347, use_depth=True)
+ camera = IntelRealSenseCamera(config)
+ camera.connect()
+ color_image, depth_map = camera.read()
+ ```
+ """
+
+ def __init__(
+ self,
+ config: IntelRealSenseCameraConfig,
+ ):
+ self.config = config
+ if config.name is not None:
+ self.serial_number = self.find_serial_number_from_name(config.name)
+ else:
+ self.serial_number = config.serial_number
+ self.fps = config.fps
+ self.width = config.width
+ self.height = config.height
+ self.channels = config.channels
+ self.color_mode = config.color_mode
+ self.use_depth = config.use_depth
+ self.force_hardware_reset = config.force_hardware_reset
+ self.mock = config.mock
+
+ self.camera = None
+ self.is_connected = False
+ self.thread = None
+ self.stop_event = None
+ self.color_image = None
+ self.depth_map = None
+ self.logs = {}
+
+ if self.mock:
+ import tests.mock_cv2 as cv2
+ else:
+ import cv2
+
+ self.rotation = None
+ if config.rotation == -90:
+ self.rotation = cv2.ROTATE_90_COUNTERCLOCKWISE
+ elif config.rotation == 90:
+ self.rotation = cv2.ROTATE_90_CLOCKWISE
+ elif config.rotation == 180:
+ self.rotation = cv2.ROTATE_180
+
+ def find_serial_number_from_name(self, name):
+ camera_infos = find_cameras()
+ camera_names = [cam["name"] for cam in camera_infos]
+ this_name_count = Counter(camera_names)[name]
+ if this_name_count > 1:
+ raise ValueError(
+ f"Multiple {name} cameras have been detected. Please use their serial number to instantiate them."
+ )
+
+ name_to_serial_dict = {cam["name"]: cam["serial_number"] for cam in camera_infos}
+ cam_sn = name_to_serial_dict[name]
+
+ return cam_sn
+
+ def connect(self):
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(
+ f"IntelRealSenseCamera({self.serial_number}) is already connected."
+ )
+
+ if self.mock:
+ import tests.mock_pyrealsense2 as rs
+ else:
+ import pyrealsense2 as rs
+
+ config = rs.config()
+ config.enable_device(str(self.serial_number))
+
+ if self.fps and self.width and self.height:
+ config.enable_stream(rs.stream.color, self.width, self.height, rs.format.rgb8, self.fps)
+ else:
+ config.enable_stream(rs.stream.color)
+
+ if self.use_depth:
+ if self.fps and self.width and self.height:
+ config.enable_stream(rs.stream.depth, self.width, self.height, rs.format.z16, self.fps)
+ else:
+ config.enable_stream(rs.stream.depth)
+
+ self.camera = rs.pipeline()
+ try:
+ profile = self.camera.start(config)
+ is_camera_open = True
+ except RuntimeError:
+ is_camera_open = False
+ traceback.print_exc()
+
+ # If the camera doesn't work, display the camera indices corresponding to
+ # valid cameras.
+ if not is_camera_open:
+ # Verify that the provided `serial_number` is valid before printing the traceback
+ camera_infos = find_cameras()
+ serial_numbers = [cam["serial_number"] for cam in camera_infos]
+ if self.serial_number not in serial_numbers:
+ raise ValueError(
+ f"`serial_number` is expected to be one of these available cameras {serial_numbers}, but {self.serial_number} is provided instead. "
+ "To find the serial number you should use, run `python lerobot/common/robot_devices/cameras/intelrealsense.py`."
+ )
+
+ raise OSError(f"Can't access IntelRealSenseCamera({self.serial_number}).")
+
+ color_stream = profile.get_stream(rs.stream.color)
+ color_profile = color_stream.as_video_stream_profile()
+ actual_fps = color_profile.fps()
+ actual_width = color_profile.width()
+ actual_height = color_profile.height()
+
+ # Using `math.isclose` since actual fps can be a float (e.g. 29.9 instead of 30)
+ if self.fps is not None and not math.isclose(self.fps, actual_fps, rel_tol=1e-3):
+ # Using `OSError` since it's a broad that encompasses issues related to device communication
+ raise OSError(
+ f"Can't set {self.fps=} for IntelRealSenseCamera({self.serial_number}). Actual value is {actual_fps}."
+ )
+ if self.width is not None and self.width != actual_width:
+ raise OSError(
+ f"Can't set {self.width=} for IntelRealSenseCamera({self.serial_number}). Actual value is {actual_width}."
+ )
+ if self.height is not None and self.height != actual_height:
+ raise OSError(
+ f"Can't set {self.height=} for IntelRealSenseCamera({self.serial_number}). Actual value is {actual_height}."
+ )
+
+ self.fps = round(actual_fps)
+ self.width = round(actual_width)
+ self.height = round(actual_height)
+
+ self.is_connected = True
+
+ def read(self, temporary_color: str | None = None) -> np.ndarray | tuple[np.ndarray, np.ndarray]:
+ """Read a frame from the camera returned in the format height x width x channels (e.g. 480 x 640 x 3)
+ of type `np.uint8`, contrarily to the pytorch format which is float channel first.
+
+ When `use_depth=True`, returns a tuple `(color_image, depth_map)` with a depth map in the format
+ height x width (e.g. 480 x 640) of type np.uint16.
+
+ Note: Reading a frame is done every `camera.fps` times per second, and it is blocking.
+ If you are reading data from other sensors, we advise to use `camera.async_read()` which is non blocking version of `camera.read()`.
+ """
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"IntelRealSenseCamera({self.serial_number}) is not connected. Try running `camera.connect()` first."
+ )
+
+ if self.mock:
+ import tests.mock_cv2 as cv2
+ else:
+ import cv2
+
+ start_time = time.perf_counter()
+
+ frame = self.camera.wait_for_frames(timeout_ms=5000)
+
+ color_frame = frame.get_color_frame()
+
+ if not color_frame:
+ raise OSError(f"Can't capture color image from IntelRealSenseCamera({self.serial_number}).")
+
+ color_image = np.asanyarray(color_frame.get_data())
+
+ requested_color_mode = self.color_mode if temporary_color is None else temporary_color
+ if requested_color_mode not in ["rgb", "bgr"]:
+ raise ValueError(
+ f"Expected color values are 'rgb' or 'bgr', but {requested_color_mode} is provided."
+ )
+
+ # IntelRealSense uses RGB format as default (red, green, blue).
+ if requested_color_mode == "bgr":
+ color_image = cv2.cvtColor(color_image, cv2.COLOR_RGB2BGR)
+
+ h, w, _ = color_image.shape
+ if h != self.height or w != self.width:
+ raise OSError(
+ f"Can't capture color image with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
+ )
+
+ if self.rotation is not None:
+ color_image = cv2.rotate(color_image, self.rotation)
+
+ # log the number of seconds it took to read the image
+ self.logs["delta_timestamp_s"] = time.perf_counter() - start_time
+
+ # log the utc time at which the image was received
+ self.logs["timestamp_utc"] = capture_timestamp_utc()
+
+ if self.use_depth:
+ depth_frame = frame.get_depth_frame()
+ if not depth_frame:
+ raise OSError(f"Can't capture depth image from IntelRealSenseCamera({self.serial_number}).")
+
+ depth_map = np.asanyarray(depth_frame.get_data())
+
+ h, w = depth_map.shape
+ if h != self.height or w != self.width:
+ raise OSError(
+ f"Can't capture depth map with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
+ )
+
+ if self.rotation is not None:
+ depth_map = cv2.rotate(depth_map, self.rotation)
+
+ return color_image, depth_map
+ else:
+ return color_image
+
+ def read_loop(self):
+ while not self.stop_event.is_set():
+ if self.use_depth:
+ self.color_image, self.depth_map = self.read()
+ else:
+ self.color_image = self.read()
+
+ def async_read(self):
+ """Access the latest color image"""
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"IntelRealSenseCamera({self.serial_number}) is not connected. Try running `camera.connect()` first."
+ )
+
+ if self.thread is None:
+ self.stop_event = threading.Event()
+ self.thread = Thread(target=self.read_loop, args=())
+ self.thread.daemon = True
+ self.thread.start()
+
+ num_tries = 0
+ while self.color_image is None:
+ num_tries += 1
+ time.sleep(1 / self.fps)
+ if num_tries > self.fps and (self.thread.ident is None or not self.thread.is_alive()):
+ raise Exception(
+ "The thread responsible for `self.async_read()` took too much time to start. There might be an issue. Verify that `self.thread.start()` has been called."
+ )
+
+ if self.use_depth:
+ return self.color_image, self.depth_map
+ else:
+ return self.color_image
+
+ def disconnect(self):
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"IntelRealSenseCamera({self.serial_number}) is not connected. Try running `camera.connect()` first."
+ )
+
+ if self.thread is not None and self.thread.is_alive():
+ # wait for the thread to finish
+ self.stop_event.set()
+ self.thread.join()
+ self.thread = None
+ self.stop_event = None
+
+ self.camera.stop()
+ self.camera = None
+
+ self.is_connected = False
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Save a few frames using `IntelRealSenseCamera` for all cameras connected to the computer, or a selected subset."
+ )
+ parser.add_argument(
+ "--serial-numbers",
+ type=int,
+ nargs="*",
+ default=None,
+ help="List of serial numbers used to instantiate the `IntelRealSenseCamera`. If not provided, find and use all available camera indices.",
+ )
+ parser.add_argument(
+ "--fps",
+ type=int,
+ default=30,
+ help="Set the number of frames recorded per seconds for all cameras. If not provided, use the default fps of each camera.",
+ )
+ parser.add_argument(
+ "--width",
+ type=str,
+ default=640,
+ help="Set the width for all cameras. If not provided, use the default width of each camera.",
+ )
+ parser.add_argument(
+ "--height",
+ type=str,
+ default=480,
+ help="Set the height for all cameras. If not provided, use the default height of each camera.",
+ )
+ parser.add_argument(
+ "--images-dir",
+ type=Path,
+ default="outputs/images_from_intelrealsense_cameras",
+ help="Set directory to save a few frames for each camera.",
+ )
+ parser.add_argument(
+ "--record-time-s",
+ type=float,
+ default=2.0,
+ help="Set the number of seconds used to record the frames. By default, 2 seconds.",
+ )
+ args = parser.parse_args()
+ save_images_from_cameras(**vars(args))
diff --git a/unitree_deploy/unitree_deploy/robot_devices/cameras/opencv.py b/unitree_deploy/unitree_deploy/robot_devices/cameras/opencv.py
new file mode 100644
index 0000000..837a789
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/cameras/opencv.py
@@ -0,0 +1,483 @@
+"""
+@misc{cadene2024lerobot,
+ author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and Gallouedec, Quentin and Zouitine, Adil and Wolf, Thomas},
+ title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in PyTorch},
+ howpublished = {Available at: https://github.com/huggingface/lerobot},
+ year = {2024},
+}
+This file contains utilities for recording frames from cameras. For more info look at `OpenCVCamera` docstring.
+"""
+
+import argparse
+import concurrent.futures
+import math
+import platform
+import shutil
+import threading
+import time
+from pathlib import Path
+from threading import Thread
+
+import cv2
+import numpy as np
+from PIL import Image
+
+from unitree_deploy.robot_devices.cameras.configs import OpenCVCameraConfig
+from unitree_deploy.robot_devices.robots_devices_utils import (
+ RobotDeviceAlreadyConnectedError,
+ RobotDeviceNotConnectedError,
+ busy_wait,
+ capture_timestamp_utc,
+)
+
+# The maximum opencv device index depends on your operating system. For instance,
+# if you have 3 cameras, they should be associated to index 0, 1, and 2. This is the case
+# on MacOS. However, on Ubuntu, the indices are different like 6, 16, 23.
+# When you change the USB port or reboot the computer, the operating system might
+# treat the same cameras as new devices. Thus we select a higher bound to search indices.
+MAX_OPENCV_INDEX = 60
+
+
+def find_cameras(raise_when_empty=False, max_index_search_range=MAX_OPENCV_INDEX, mock=False) -> list[dict]:
+ cameras = []
+ if platform.system() == "Linux":
+ print("Linux detected. Finding available camera indices through scanning '/dev/video*' ports")
+ possible_ports = [str(port) for port in Path("/dev").glob("video*")]
+ ports = _find_cameras(possible_ports, mock=mock)
+ for port in ports:
+ cameras.append(
+ {
+ "port": port,
+ "index": int(port.removeprefix("/dev/video")),
+ }
+ )
+ else:
+ print(
+ "Mac or Windows detected. Finding available camera indices through "
+ f"scanning all indices from 0 to {MAX_OPENCV_INDEX}"
+ )
+ possible_indices = range(max_index_search_range)
+ indices = _find_cameras(possible_indices, mock=mock)
+ for index in indices:
+ cameras.append(
+ {
+ "port": None,
+ "index": index,
+ }
+ )
+
+ return cameras
+
+
+def _find_cameras(
+ possible_camera_ids: list[int | str], raise_when_empty=False, mock=False
+) -> list[int | str]:
+ camera_ids = []
+ for camera_idx in possible_camera_ids:
+ camera = cv2.VideoCapture(camera_idx)
+ is_open = camera.isOpened()
+ camera.release()
+
+ if is_open:
+ print(f"Camera found at index {camera_idx}")
+ camera_ids.append(camera_idx)
+
+ if raise_when_empty and len(camera_ids) == 0:
+ raise OSError(
+ "Not a single camera was detected. Try re-plugging, or re-installing `opencv2`, "
+ "or your camera driver, or make sure your camera is compatible with opencv2."
+ )
+
+ return camera_ids
+
+
+def is_valid_unix_path(path: str) -> bool:
+ """Note: if 'path' points to a symlink, this will return True only if the target exists"""
+ p = Path(path)
+ return p.is_absolute() and p.exists()
+
+
+def get_camera_index_from_unix_port(port: Path) -> int:
+ return int(str(port.resolve()).removeprefix("/dev/video"))
+
+
+def save_image(img_array, camera_index, frame_index, images_dir):
+ img = Image.fromarray(img_array)
+ path = images_dir / f"camera_{camera_index:02d}_frame_{frame_index:06d}.png"
+ path.parent.mkdir(parents=True, exist_ok=True)
+ img.save(str(path), quality=100)
+
+
+def save_images_from_cameras(
+ images_dir: Path,
+ camera_ids: list | None = None,
+ fps=None,
+ width=None,
+ height=None,
+ record_time_s=2,
+ mock=False,
+):
+ """
+ Initializes all the cameras and saves images to the directory. Useful to visually identify the camera
+ associated to a given camera index.
+ """
+ if camera_ids is None or len(camera_ids) == 0:
+ camera_infos = find_cameras(mock=mock)
+ camera_ids = [cam["index"] for cam in camera_infos]
+
+ print("Connecting cameras")
+ cameras = []
+ for cam_idx in camera_ids:
+ config = OpenCVCameraConfig(camera_index=cam_idx, fps=fps, width=width, height=height, mock=mock)
+ camera = OpenCVCamera(config)
+ camera.connect()
+ print(
+ f"OpenCVCamera({camera.camera_index}, fps={camera.fps}, width={camera.width}, "
+ f"height={camera.height}, color_mode={camera.color_mode})"
+ )
+ cameras.append(camera)
+
+ images_dir = Path(images_dir)
+ if images_dir.exists():
+ shutil.rmtree(
+ images_dir,
+ )
+ images_dir.mkdir(parents=True, exist_ok=True)
+
+ print(f"Saving images to {images_dir}")
+ frame_index = 0
+ start_time = time.perf_counter()
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as executor:
+ while True:
+ now = time.perf_counter()
+
+ for camera in cameras:
+ # If we use async_read when fps is None, the loop will go full speed, and we will endup
+ # saving the same images from the cameras multiple times until the RAM/disk is full.
+ image = camera.read() if fps is None else camera.async_read()
+
+ executor.submit(
+ save_image,
+ image,
+ camera.camera_index,
+ frame_index,
+ images_dir,
+ )
+
+ if fps is not None:
+ dt_s = time.perf_counter() - now
+ busy_wait(1 / fps - dt_s)
+
+ print(f"Frame: {frame_index:04d}\tLatency (ms): {(time.perf_counter() - now) * 1000:.2f}")
+
+ if time.perf_counter() - start_time > record_time_s:
+ break
+
+ frame_index += 1
+
+ print(f"Images have been saved to {images_dir}")
+
+
+class OpenCVCamera:
+ """
+ The OpenCVCamera class allows to efficiently record images from cameras. It relies on opencv2 to communicate
+ with the cameras. Most cameras are compatible. For more info, see the [Video I/O with OpenCV Overview](https://docs.opencv.org/4.x/d0/da7/videoio_overview.html).
+
+ An OpenCVCamera instance requires a camera index (e.g. `OpenCVCamera(camera_index=0)`). When you only have one camera
+ like a webcam of a laptop, the camera index is expected to be 0, but it might also be very different, and the camera index
+ might change if you reboot your computer or re-plug your camera. This behavior depends on your operation system.
+
+ To find the camera indices of your cameras, you can run our utility script that will be save a few frames for each camera:
+ ```bash
+ python lerobot/common/robot_devices/cameras/opencv.py --images-dir outputs/images_from_opencv_cameras
+ ```
+
+ When an OpenCVCamera is instantiated, if no specific config is provided, the default fps, width, height and color_mode
+ of the given camera will be used.
+
+ Example of usage:
+ ```python
+ from lerobot.common.robot_devices.cameras.configs import OpenCVCameraConfig
+
+ config = OpenCVCameraConfig(camera_index=0)
+ camera = OpenCVCamera(config)
+ camera.connect()
+ color_image = camera.read()
+ # when done using the camera, consider disconnecting
+ camera.disconnect()
+ ```
+
+ Example of changing default fps, width, height and color_mode:
+ ```python
+ config = OpenCVCameraConfig(camera_index=0, fps=30, width=1280, height=720)
+ config = OpenCVCameraConfig(camera_index=0, fps=90, width=640, height=480)
+ config = OpenCVCameraConfig(camera_index=0, fps=90, width=640, height=480, color_mode="bgr")
+ # Note: might error out open `camera.connect()` if these settings are not compatible with the camera
+ ```
+ """
+
+ def __init__(self, config: OpenCVCameraConfig):
+ self.config = config
+ self.camera_index = config.camera_index
+ self.port = None
+
+ # Linux uses ports for connecting to cameras
+ if platform.system() == "Linux":
+ if isinstance(self.camera_index, int):
+ self.port = Path(f"/dev/video{self.camera_index}")
+ elif isinstance(self.camera_index, str) and is_valid_unix_path(self.camera_index):
+ self.port = Path(self.camera_index)
+ # Retrieve the camera index from a potentially symlinked path
+ self.camera_index = get_camera_index_from_unix_port(self.port)
+ else:
+ raise ValueError(f"Please check the provided camera_index: {self.camera_index}")
+
+ self.fps = config.fps
+ self.width = config.width
+ self.height = config.height
+ self.channels = config.channels
+ self.color_mode = config.color_mode
+ self.mock = config.mock
+
+ self.camera = None
+ self.is_connected = False
+ self.thread = None
+ self.stop_event = None
+ self.color_image = None
+ self.logs = {}
+
+ if self.mock:
+ import tests.mock_cv2 as cv2
+ else:
+ import cv2
+
+ self.rotation = None
+ if config.rotation == -90:
+ self.rotation = cv2.ROTATE_90_COUNTERCLOCKWISE
+ elif config.rotation == 90:
+ self.rotation = cv2.ROTATE_90_CLOCKWISE
+ elif config.rotation == 180:
+ self.rotation = cv2.ROTATE_180
+
+ def connect(self):
+ if self.is_connected:
+ raise RobotDeviceAlreadyConnectedError(f"OpenCVCamera({self.camera_index}) is already connected.")
+
+ if self.mock:
+ import tests.mock_cv2 as cv2
+ else:
+ import cv2
+
+ # Use 1 thread to avoid blocking the main thread. Especially useful during data collection
+ # when other threads are used to save the images.
+ cv2.setNumThreads(1)
+
+ camera_idx = f"/dev/video{self.camera_index}" if platform.system() == "Linux" else self.camera_index
+ # First create a temporary camera trying to access `camera_index`,
+ # and verify it is a valid camera by calling `isOpened`.
+ tmp_camera = cv2.VideoCapture(camera_idx)
+ is_camera_open = tmp_camera.isOpened()
+ # Release camera to make it accessible for `find_camera_indices`
+ tmp_camera.release()
+ del tmp_camera
+
+ # If the camera doesn't work, display the camera indices corresponding to
+ # valid cameras.
+ if not is_camera_open:
+ # Verify that the provided `camera_index` is valid before printing the traceback
+ cameras_info = find_cameras()
+ available_cam_ids = [cam["index"] for cam in cameras_info]
+ if self.camera_index not in available_cam_ids:
+ raise ValueError(
+ f"`camera_index` is expected to be one of these available cameras {available_cam_ids}, but {self.camera_index} is provided instead. "
+ "To find the camera index you should use, run `python lerobot/common/robot_devices/cameras/opencv.py`."
+ )
+
+ raise OSError(f"Can't access OpenCVCamera({camera_idx}).")
+
+ # Secondly, create the camera that will be used downstream.
+ # Note: For some unknown reason, calling `isOpened` blocks the camera which then
+ # needs to be re-created.
+ self.camera = cv2.VideoCapture(camera_idx)
+
+ if self.fps is not None:
+ self.camera.set(cv2.CAP_PROP_FPS, self.fps)
+ if self.width is not None:
+ self.camera.set(cv2.CAP_PROP_FRAME_WIDTH, self.width)
+ if self.height is not None:
+ self.camera.set(cv2.CAP_PROP_FRAME_HEIGHT, self.height)
+
+ actual_fps = self.camera.get(cv2.CAP_PROP_FPS)
+ actual_width = self.camera.get(cv2.CAP_PROP_FRAME_WIDTH)
+ actual_height = self.camera.get(cv2.CAP_PROP_FRAME_HEIGHT)
+
+ # Using `math.isclose` since actual fps can be a float (e.g. 29.9 instead of 30)
+ if self.fps is not None and not math.isclose(self.fps, actual_fps, rel_tol=1e-3):
+ # Using `OSError` since it's a broad that encompasses issues related to device communication
+ raise OSError(
+ f"Can't set {self.fps=} for OpenCVCamera({self.camera_index}). Actual value is {actual_fps}."
+ )
+ if self.width is not None and not math.isclose(self.width, actual_width, rel_tol=1e-3):
+ raise OSError(
+ f"Can't set {self.width=} for OpenCVCamera({self.camera_index}). Actual value is {actual_width}."
+ )
+ if self.height is not None and not math.isclose(self.height, actual_height, rel_tol=1e-3):
+ raise OSError(
+ f"Can't set {self.height=} for OpenCVCamera({self.camera_index}). Actual value is {actual_height}."
+ )
+
+ self.fps = round(actual_fps)
+ self.width = round(actual_width)
+ self.height = round(actual_height)
+
+ self.is_connected = True
+
+ def read(self, temporary_color_mode: str | None = None) -> np.ndarray:
+ """Read a frame from the camera returned in the format (height, width, channels)
+ (e.g. 480 x 640 x 3), contrarily to the pytorch format which is channel first.
+
+ Note: Reading a frame is done every `camera.fps` times per second, and it is blocking.
+ If you are reading data from other sensors, we advise to use `camera.async_read()` which is non blocking version of `camera.read()`.
+ """
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"OpenCVCamera({self.camera_index}) is not connected. Try running `camera.connect()` first."
+ )
+
+ start_time = time.perf_counter()
+
+ ret, color_image = self.camera.read()
+
+ if not ret:
+ raise OSError(f"Can't capture color image from camera {self.camera_index}.")
+
+ requested_color_mode = self.color_mode if temporary_color_mode is None else temporary_color_mode
+
+ if requested_color_mode not in ["rgb", "bgr"]:
+ raise ValueError(
+ f"Expected color values are 'rgb' or 'bgr', but {requested_color_mode} is provided."
+ )
+
+ # OpenCV uses BGR format as default (blue, green, red) for all operations, including displaying images.
+ # However, Deep Learning framework such as LeRobot uses RGB format as default to train neural networks,
+ # so we convert the image color from BGR to RGB.
+ if requested_color_mode == "rgb":
+ if self.mock:
+ import tests.mock_cv2 as cv2
+ else:
+ import cv2
+
+ color_image = cv2.cvtColor(color_image, cv2.COLOR_BGR2RGB)
+
+ h, w, _ = color_image.shape
+ if h != self.height or w != self.width:
+ raise OSError(
+ f"Can't capture color image with expected height and width ({self.height} x {self.width}). ({h} x {w}) returned instead."
+ )
+
+ if self.rotation is not None:
+ color_image = cv2.rotate(color_image, self.rotation)
+
+ # log the number of seconds it took to read the image
+ self.logs["delta_timestamp_s"] = time.perf_counter() - start_time
+
+ # log the utc time at which the image was received
+ self.logs["timestamp_utc"] = capture_timestamp_utc()
+
+ self.color_image = color_image
+
+ return color_image
+
+ def read_loop(self):
+ while not self.stop_event.is_set():
+ try:
+ self.color_image = self.read()
+ except Exception as e:
+ print(f"Error reading in thread: {e}")
+
+ def async_read(self):
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"OpenCVCamera({self.camera_index}) is not connected. Try running `camera.connect()` first."
+ )
+
+ if self.thread is None:
+ self.stop_event = threading.Event()
+ self.thread = Thread(target=self.read_loop, args=())
+ self.thread.daemon = True
+ self.thread.start()
+
+ num_tries = 0
+ while True:
+ if self.color_image is not None:
+ return self.color_image
+
+ time.sleep(1 / self.fps)
+ num_tries += 1
+ if num_tries > self.fps * 2:
+ raise TimeoutError("Timed out waiting for async_read() to start.")
+
+ def disconnect(self):
+ if not self.is_connected:
+ raise RobotDeviceNotConnectedError(
+ f"OpenCVCamera({self.camera_index}) is not connected. Try running `camera.connect()` first."
+ )
+
+ if self.thread is not None:
+ self.stop_event.set()
+ self.thread.join() # wait for the thread to finish
+ self.thread = None
+ self.stop_event = None
+
+ self.camera.release()
+ self.camera = None
+ self.is_connected = False
+
+ def __del__(self):
+ if getattr(self, "is_connected", False):
+ self.disconnect()
+
+
+if __name__ == "__main__":
+ parser = argparse.ArgumentParser(
+ description="Save a few frames using `OpenCVCamera` for all cameras connected to the computer, or a selected subset."
+ )
+ parser.add_argument(
+ "--camera-ids",
+ type=int,
+ nargs="*",
+ default=None,
+ help="List of camera indices used to instantiate the `OpenCVCamera`. If not provided, find and use all available camera indices.",
+ )
+ parser.add_argument(
+ "--fps",
+ type=int,
+ default=30,
+ help="Set the number of frames recorded per seconds for all cameras. If not provided, use the default fps of each camera.",
+ )
+ parser.add_argument(
+ "--width",
+ type=str,
+ default=640,
+ help="Set the width for all cameras. If not provided, use the default width of each camera.",
+ )
+ parser.add_argument(
+ "--height",
+ type=str,
+ default=480,
+ help="Set the height for all cameras. If not provided, use the default height of each camera.",
+ )
+ parser.add_argument(
+ "--images-dir",
+ type=Path,
+ default="outputs/images_from_opencv_cameras",
+ help="Set directory to save a few frames for each camera.",
+ )
+ parser.add_argument(
+ "--record-time-s",
+ type=float,
+ default=4.0,
+ help="Set the number of seconds used to record the frames. By default, 2 seconds.",
+ )
+ args = parser.parse_args()
+ save_images_from_cameras(**vars(args))
diff --git a/unitree_deploy/unitree_deploy/robot_devices/cameras/utils.py b/unitree_deploy/unitree_deploy/robot_devices/cameras/utils.py
new file mode 100644
index 0000000..4eead3a
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/cameras/utils.py
@@ -0,0 +1,74 @@
+"""
+@misc{cadene2024lerobot,
+ author = {Cadene, Remi and Alibert, Simon and Soare, Alexander and Gallouedec, Quentin and Zouitine, Adil and Wolf, Thomas},
+ title = {LeRobot: State-of-the-art Machine Learning for Real-World Robotics in PyTorch},
+ howpublished = {Available at: https://github.com/huggingface/lerobot},
+ year = {2024},
+}
+"""
+
+from typing import Protocol
+
+import numpy as np
+
+from unitree_deploy.robot_devices.cameras.configs import (
+ CameraConfig,
+ ImageClientCameraConfig,
+ IntelRealSenseCameraConfig,
+ OpenCVCameraConfig,
+)
+
+
+# Defines a camera type
+class Camera(Protocol):
+ def connect(self): ...
+ def read(self, temporary_color: str | None = None) -> np.ndarray: ...
+ def async_read(self) -> np.ndarray: ...
+ def disconnect(self): ...
+
+
+def make_cameras_from_configs(camera_configs: dict[str, CameraConfig]) -> list[Camera]:
+ cameras = {}
+
+ for key, cfg in camera_configs.items():
+ if cfg.type == "opencv":
+ from unitree_deploy.robot_devices.cameras.opencv import OpenCVCamera
+
+ cameras[key] = OpenCVCamera(cfg)
+
+ elif cfg.type == "intelrealsense":
+ from unitree_deploy.robot_devices.cameras.intelrealsense import IntelRealSenseCamera
+
+ cameras[key] = IntelRealSenseCamera(cfg)
+
+ elif cfg.type == "imageclient":
+ from unitree_deploy.robot_devices.cameras.imageclient import ImageClientCamera
+
+ cameras[key] = ImageClientCamera(cfg)
+ else:
+ raise ValueError(f"The motor type '{cfg.type}' is not valid.")
+
+ return cameras
+
+
+def make_camera(camera_type, **kwargs) -> Camera:
+ if camera_type == "opencv":
+ from unitree_deploy.robot_devices.cameras.opencv import OpenCVCamera
+
+ config = OpenCVCameraConfig(**kwargs)
+ return OpenCVCamera(config)
+
+ elif camera_type == "intelrealsense":
+ from unitree_deploy.robot_devices.cameras.intelrealsense import IntelRealSenseCamera
+
+ config = IntelRealSenseCameraConfig(**kwargs)
+ return IntelRealSenseCamera(config)
+
+ elif camera_type == "imageclient":
+ from unitree_deploy.robot_devices.cameras.imageclient import ImageClientCamera
+
+ config = ImageClientCameraConfig(**kwargs)
+ return ImageClientCamera(config)
+
+ else:
+ raise ValueError(f"The camera type '{camera_type}' is not valid.")
diff --git a/unitree_deploy/unitree_deploy/robot_devices/endeffector/configs.py b/unitree_deploy/unitree_deploy/robot_devices/endeffector/configs.py
new file mode 100644
index 0000000..0b51f15
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/endeffector/configs.py
@@ -0,0 +1,29 @@
+import abc
+from dataclasses import dataclass
+
+import draccus
+import numpy as np
+
+
+@dataclass
+class EndEffectorConfig(draccus.ChoiceRegistry, abc.ABC):
+ @property
+ def type(self) -> str:
+ return self.get_choice_name(self.__class__)
+
+
+@EndEffectorConfig.register_subclass("dex_1")
+@dataclass
+class Dex1_GripperConfig(EndEffectorConfig):
+ motors: dict[str, tuple[int, str]]
+ unit_test: bool = False
+ init_pose: list | None = None
+ control_dt: float = 1 / 200
+ mock: bool = False
+ max_pos_speed: float = 180 * (np.pi / 180) * 2
+ topic_gripper_command: str = "rt/unitree_actuator/cmd"
+ topic_gripper_state: str = "rt/unitree_actuator/state"
+
+ def __post_init__(self):
+ if self.control_dt < 0.002:
+ raise ValueError(f"`control_dt` must > 1/500 (got {self.control_dt})")
diff --git a/unitree_deploy/unitree_deploy/robot_devices/endeffector/gripper.py b/unitree_deploy/unitree_deploy/robot_devices/endeffector/gripper.py
new file mode 100644
index 0000000..c8d7a4b
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/endeffector/gripper.py
@@ -0,0 +1,227 @@
+# for gripper
+import threading
+import time
+
+import numpy as np
+from unitree_sdk2py.core.channel import ChannelFactoryInitialize, ChannelPublisher, ChannelSubscriber # dds
+from unitree_sdk2py.idl.default import unitree_go_msg_dds__MotorCmd_
+from unitree_sdk2py.idl.unitree_go.msg.dds_ import MotorCmds_, MotorStates_ # idl
+
+from unitree_deploy.robot_devices.arm.arm_indexs import Gripper_Sigle_JointIndex
+from unitree_deploy.robot_devices.endeffector.configs import Dex1_GripperConfig
+from unitree_deploy.robot_devices.robots_devices_utils import DataBuffer, MotorState, Robot_Num_Motors
+from unitree_deploy.utils.joint_trajcetory_inter import JointTrajectoryInterpolator
+from unitree_deploy.utils.rich_logger import log_error, log_info, log_warning
+
+
+class Gripper_LowState:
+ def __init__(self):
+ self.motor_state = [MotorState() for _ in range(Robot_Num_Motors.Dex1_Gripper_Num_Motors)]
+
+
+class Dex1_Gripper_Controller:
+ def __init__(self, config: Dex1_GripperConfig):
+ log_info("Initialize Dex1_Gripper_Controller...")
+
+ self.init_pose = np.array(config.init_pose)
+
+ self.motors = config.motors
+ self.mock = config.mock
+ self.control_dt = config.control_dt
+ self.unit_test = config.unit_test
+ self.max_pos_speed = config.max_pos_speed
+
+ self.topic_gripper_command = config.topic_gripper_command
+ self.topic_gripper_state = config.topic_gripper_state
+
+ self.q_target = np.zeros(1)
+ self.tauff_target = np.zeros(1)
+ self.time_target = time.monotonic()
+ self.gripper_cmd = "schedule_waypoint"
+
+ self.lowstate_buffer = DataBuffer()
+ self.ctrl_lock = threading.Lock()
+
+ self.MAX_DIST = 5.45
+ self.MIN_DIST = 0.0
+ self.DELTA_GRIPPER_CMD = 0.18
+
+ self.is_connected = False
+
+ @property
+ def motor_names(self) -> list[str]:
+ return list(self.motors.keys())
+
+ @property
+ def motor_models(self) -> list[str]:
+ return [model for _, model in self.motors.values()]
+
+ @property
+ def motor_indices(self) -> list[int]:
+ return [idx for idx, _ in self.motors.values()]
+
+ def connect(self):
+ try:
+ if self.unit_test:
+ ChannelFactoryInitialize(0)
+
+ dq = 0.0
+ tau = 0.0
+ kp = 10.0
+ kd = 0.05
+
+ # initialize gripper cmd msg
+ self.gripper_msg = MotorCmds_()
+ self.gripper_msg.cmds = [unitree_go_msg_dds__MotorCmd_() for _ in range(len(Gripper_Sigle_JointIndex))]
+ for id in Gripper_Sigle_JointIndex:
+ self.gripper_msg.cmds[id].dq = dq
+ self.gripper_msg.cmds[id].tau_est = tau
+ self.gripper_msg.cmds[id].kp = kp
+ self.gripper_msg.cmds[id].kd = kd
+
+ # initialize handcmd publisher and handstate subscriber
+ self.GripperCmb_publisher = ChannelPublisher(self.topic_gripper_command, MotorCmds_)
+ self.GripperCmb_publisher.Init()
+
+ self.GripperState_subscriber = ChannelSubscriber(self.topic_gripper_state, MotorStates_)
+ self.GripperState_subscriber.Init()
+
+ # initialize subscribe thread
+ self.subscribe_state_thread = threading.Thread(target=self._subscribe_gripper_motor_state)
+ self.subscribe_state_thread.daemon = True
+ self.subscribe_state_thread.start()
+
+ while not self.lowstate_buffer.get_data():
+ time.sleep(0.01)
+ log_warning("[Dex1_Gripper_Controller] Waiting to subscribe dds...")
+
+ self.gripper_control_thread = threading.Thread(target=self._ctrl_gripper_motor)
+ self.gripper_control_thread.daemon = True
+ self.gripper_control_thread.start()
+
+ self.is_connected = True
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in Dex1_Gripper_Controller.connect: {e}")
+
+ def _subscribe_gripper_motor_state(self):
+ try:
+ while True:
+ gripper_msg = self.GripperState_subscriber.Read()
+ if gripper_msg is not None:
+ lowstate = Gripper_LowState()
+ for idx, id in enumerate(Gripper_Sigle_JointIndex):
+ lowstate.motor_state[idx].q = gripper_msg.states[id].q
+ lowstate.motor_state[idx].dq = gripper_msg.states[id].dq
+ self.lowstate_buffer.set_data(lowstate)
+ time.sleep(0.002)
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in Dex1_Gripper_Controller._subscribe_gripper_motor_state: {e}")
+
+ def _update_gripper(self, gripper_q_target: np.ndarray):
+ current_qs = np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in Gripper_Sigle_JointIndex])
+ clamped_qs = np.clip(gripper_q_target, current_qs - self.DELTA_GRIPPER_CMD, current_qs + self.DELTA_GRIPPER_CMD)
+ """set current left, right gripper motor state target q"""
+ for idx, id in enumerate(Gripper_Sigle_JointIndex):
+ self.gripper_msg.cmds[id].q = np.array(clamped_qs)[idx]
+ self.GripperCmb_publisher.Write(self.gripper_msg)
+
+ def _drive_to_waypoint(self, target_pose: np.ndarray, t_insert_time: float):
+ curr_time = time.monotonic() + self.control_dt
+ t_insert = curr_time + t_insert_time
+ self.pose_interp = self.pose_interp.drive_to_waypoint(
+ pose=target_pose,
+ time=t_insert,
+ curr_time=curr_time,
+ max_pos_speed=self.max_pos_speed,
+ )
+
+ while time.monotonic() < t_insert:
+ self._update_gripper(self.pose_interp(time.monotonic()))
+ time.sleep(self.control_dt)
+
+ def _ctrl_gripper_motor(self):
+ try:
+ self.pose_interp = JointTrajectoryInterpolator(
+ times=[time.monotonic()],
+ joint_positions=[self.read_current_endeffector_q()],
+ )
+
+ gripper_q_target = self.read_current_endeffector_q()
+ gripper_tauff_target = self.tauff_target
+ gripper_time_target = time.monotonic()
+ gripper_cmd = "schedule_waypoint"
+
+ last_waypoint_time = time.monotonic()
+ while True:
+ start_time = time.perf_counter()
+ t_now = time.monotonic()
+ with self.ctrl_lock:
+ gripper_q_target = self.q_target
+ gripper_tauff_target = self.tauff_target # noqa: F841
+ gripper_time_target = self.time_target
+ gripper_cmd = self.gripper_cmd
+
+ if gripper_cmd is None:
+ self._update_gripper(gripper_q_target)
+ # time.sleep(max(0, (self.control_dt - (time.perf_counter() - start_time))))
+ elif gripper_cmd == "drive_to_waypoint":
+ self._drive_to_waypoint(target_pose=gripper_q_target, t_insert_time=0.8)
+
+ elif gripper_cmd == "schedule_waypoint":
+ target_time = time.monotonic() - time.perf_counter() + gripper_time_target
+ curr_time = t_now + self.control_dt
+ target_time = max(target_time, curr_time + self.control_dt)
+
+ self.pose_interp = self.pose_interp.schedule_waypoint(
+ pose=gripper_q_target,
+ time=target_time,
+ max_pos_speed=self.max_pos_speed,
+ curr_time=curr_time,
+ last_waypoint_time=last_waypoint_time,
+ )
+ last_waypoint_time = target_time
+
+ self._update_gripper(self.pose_interp(t_now))
+ time.sleep(max(0, (self.control_dt - (time.perf_counter() - start_time))))
+
+ except Exception as e:
+ self.disconnect()
+ log_error(f"β Error in Dex1_Gripper_Controller._ctrl_gripper_motor: {e}")
+
+ def read_current_endeffector_q(self) -> np.ndarray:
+ # Motor inversion left is 1 and right is 0 TODO(gh): Correct this
+ motor_states = np.array([self.lowstate_buffer.get_data().motor_state[id].q for id in Gripper_Sigle_JointIndex])
+ return np.array(motor_states)
+
+ def read_current_endeffector_dq(self) -> np.ndarray:
+ # Motor inversion left is 1 and right is 0 TODO(gh): Correct this
+ motor_states_dq = np.array(
+ [self.lowstate_buffer.get_data().motor_state[id].dq for id in Gripper_Sigle_JointIndex]
+ )
+ return np.array(motor_states_dq)
+
+ def write_endeffector(
+ self,
+ q_target: list[float] | np.ndarray,
+ tauff_target: list[float] | np.ndarray = None,
+ time_target: float | None = None,
+ cmd_target: str | None = None,
+ ):
+ with self.ctrl_lock:
+ self.q_target = q_target
+ self.tauff_target = tauff_target
+ self.time_target = time_target
+ self.gripper_cmd = cmd_target
+
+ def go_start(self):
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=0.8)
+
+ def go_home(self):
+ self._drive_to_waypoint(target_pose=self.init_pose, t_insert_time=0.8)
+
+ def disconnect(self):
+ self.is_connected = False
+ # self.go_home()
diff --git a/unitree_deploy/unitree_deploy/robot_devices/endeffector/utils.py b/unitree_deploy/unitree_deploy/robot_devices/endeffector/utils.py
new file mode 100644
index 0000000..fa6f467
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/endeffector/utils.py
@@ -0,0 +1,50 @@
+from typing import Protocol
+
+from unitree_deploy.robot_devices.endeffector.configs import (
+ Dex1_GripperConfig,
+ EndEffectorConfig,
+)
+
+
+class EndEffector(Protocol):
+ def connect(self): ...
+ def disconnect(self): ...
+ def motor_names(self): ...
+
+ def read_current_endeffector_q(self): ...
+ def read_current_endeffector_dq(self): ...
+ def write_endeffector(self): ...
+
+ def retarget_to_endeffector(self): ...
+ def endeffector_ik(self): ...
+
+ def go_start(self): ...
+ def go_home(self): ...
+
+
+def make_endeffector_motors_buses_from_configs(
+ endeffector_configs: dict[str, EndEffectorConfig],
+) -> list[EndEffectorConfig]:
+ endeffector_motors_buses = {}
+
+ for key, cfg in endeffector_configs.items():
+ if cfg.type == "dex_1":
+ from unitree_deploy.robot_devices.endeffector.gripper import Dex1_Gripper_Controller
+
+ endeffector_motors_buses[key] = Dex1_Gripper_Controller(cfg)
+
+ else:
+ raise ValueError(f"The motor type '{cfg.type}' is not valid.")
+
+ return endeffector_motors_buses
+
+
+def make_endeffector_motors_bus(endeffector_type: str, **kwargs) -> EndEffectorConfig:
+ if endeffector_type == "dex_1":
+ from unitree_deploy.robot_devices.endeffector.gripper import Dex1_Gripper_Controller
+
+ config = Dex1_GripperConfig(**kwargs)
+ return Dex1_Gripper_Controller(config)
+
+ else:
+ raise ValueError(f"The motor type '{endeffector_type}' is not valid.")
diff --git a/unitree_deploy/unitree_deploy/robot_devices/robots_devices_utils.py b/unitree_deploy/unitree_deploy/robot_devices/robots_devices_utils.py
new file mode 100644
index 0000000..fcac5e4
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/robot_devices/robots_devices_utils.py
@@ -0,0 +1,87 @@
+import platform
+import threading
+import time
+from dataclasses import dataclass
+from datetime import datetime, timezone
+from enum import IntEnum
+from typing import Optional
+
+
+class Robot_Num_Motors(IntEnum):
+ Z1_6_Num_Motors = 6
+ Z1_7_Num_Motors = 7
+ Z1_12_Num_Motors = 12
+
+ Dex1_Gripper_Num_Motors = 2
+ G1_29_Num_Motors = 35
+
+
+@dataclass
+class MotorState:
+ q: Optional[float] = None
+ dq: Optional[float] = None
+ tau: Optional[float] = None
+
+
+class DataBuffer:
+ def __init__(self) -> None:
+ self.data = None
+ self.lock = threading.Lock()
+
+ def get_data(self):
+ with self.lock:
+ return self.data
+
+ def set_data(self, data) -> None:
+ with self.lock:
+ self.data = data
+
+
+class RobotDeviceNotConnectedError(Exception):
+ """Exception raised when the robot device is not connected."""
+
+ def __init__(
+ self, message="This robot device is not connected. Try calling `robot_device.connect()` first."
+ ):
+ self.message = message
+ super().__init__(self.message)
+
+
+class RobotDeviceAlreadyConnectedError(Exception):
+ """Exception raised when the robot device is already connected."""
+
+ def __init__(
+ self,
+ message="This robot device is already connected. Try not calling `robot_device.connect()` twice.",
+ ):
+ self.message = message
+ super().__init__(self.message)
+
+
+def capture_timestamp_utc():
+ return datetime.now(timezone.utc)
+
+
+def busy_wait(seconds):
+ if platform.system() == "Darwin":
+ # On Mac, `time.sleep` is not accurate and we need to use this while loop trick,
+ # but it consumes CPU cycles.
+ end_time = time.perf_counter() + seconds
+ while time.perf_counter() < end_time:
+ pass
+ else:
+ # On Linux time.sleep is accurate
+ if seconds > 0:
+ time.sleep(seconds)
+
+
+def precise_wait(t_end: float, slack_time: float = 0.001, time_func=time.monotonic):
+ t_start = time_func()
+ t_wait = t_end - t_start
+ if t_wait > 0:
+ t_sleep = t_wait - slack_time
+ if t_sleep > 0:
+ time.sleep(t_sleep)
+ while time_func() < t_end:
+ pass
+ return
diff --git a/unitree_deploy/unitree_deploy/utils/eval_utils.py b/unitree_deploy/unitree_deploy/utils/eval_utils.py
new file mode 100644
index 0000000..97afea7
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/eval_utils.py
@@ -0,0 +1,281 @@
+import logging
+import sys
+import time
+import traceback
+import warnings
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any, ClassVar
+
+import cv2
+import numpy as np
+import pandas as pd
+import pyarrow as pa
+import requests
+import torch
+import torchvision
+from datasets import load_from_disk
+from datasets.features.features import register_feature
+from safetensors.torch import load_file
+
+logging.basicConfig(stream=sys.stdout, level=logging.DEBUG)
+
+
+class LongConnectionClient:
+ def __init__(self, base_url):
+ self.session = requests.Session()
+ self.base_url = base_url
+
+ def send_post(self, endpoint, json_data):
+ """send POST request to endpoint"""
+ url = f"{self.base_url}{endpoint}"
+ response = None
+ while True:
+ try:
+ response = self.session.post(url, json=json_data)
+ if response.status_code == 200:
+ data = response.json()
+ if data["result"] == "ok":
+ response = data
+ break
+ else:
+ logging.info(data["desc"])
+
+ time.sleep(1)
+ except Exception as e:
+ logging.error(f"An error occurred: {e}")
+ logging.error(traceback.format_exc())
+
+ return response
+
+ def close(self):
+ """ "close session"""
+ self.session.close()
+
+ def predict_action(self, language_instruction, batch) -> torch.Tensor:
+ # collect data
+ data = {
+ "language_instruction": language_instruction,
+ "observation.state": torch.stack(list(batch["observation.state"])).tolist(),
+ "observation.images.top": torch.stack(list(batch["observation.images.top"])).tolist(),
+ "action": torch.stack(list(batch["action"])).tolist(),
+ }
+
+ # send data
+ endpoint = "/predict_action"
+ response = self.send_post(endpoint, data)
+ # action = torch.tensor(response['action']).unsqueeze(0)
+ action = torch.tensor(response["action"])
+ return action
+
+
+class ACTTemporalEnsembler:
+ def __init__(self, temporal_ensemble_coeff: float, chunk_size: int, exe_steps: int) -> None:
+ """Temporal ensembling as described in Algorithm 2 of https://arxiv.org/abs/2304.13705.
+
+ The weights are calculated as wα΅’ = exp(-temporal_ensemble_coeff * i) where wβ is the oldest action.
+ They are then normalized to sum to 1 by dividing by Ξ£wα΅’. Here's some intuition around how the
+ coefficient works:
+ - Setting it to 0 uniformly weighs all actions.
+ - Setting it positive gives more weight to older actions.
+ - Setting it negative gives more weight to newer actions.
+ NOTE: The default value for `temporal_ensemble_coeff` used by the original ACT work is 0.01. This
+ results in older actions being weighed more highly than newer actions (the experiments documented in
+ https://github.com/huggingface/lerobot/pull/319 hint at why highly weighing new actions might be
+ detrimental: doing so aggressively may diminish the benefits of action chunking).
+
+ Here we use an online method for computing the average rather than caching a history of actions in
+ order to compute the average offline. For a simple 1D sequence it looks something like:
+
+ ```
+ import torch
+
+ seq = torch.linspace(8, 8.5, 100)
+ print(seq)
+
+ m = 0.01
+ exp_weights = torch.exp(-m * torch.arange(len(seq)))
+ print(exp_weights)
+
+ # Calculate offline
+ avg = (exp_weights * seq).sum() / exp_weights.sum()
+ print("offline", avg)
+
+ # Calculate online
+ for i, item in enumerate(seq):
+ if i == 0:
+ avg = item
+ continue
+ avg *= exp_weights[:i].sum()
+ avg += item * exp_weights[i]
+ avg /= exp_weights[:i+1].sum()
+ print("online", avg)
+ ```
+ """
+ self.chunk_size = chunk_size
+ self.ensemble_weights = torch.exp(-temporal_ensemble_coeff * torch.arange(chunk_size))
+ self.ensemble_weights_cumsum = torch.cumsum(self.ensemble_weights, dim=0)
+ self.exe_steps = exe_steps
+ self.reset()
+
+ def reset(self):
+ """Resets the online computation variables."""
+ self.ensembled_actions = None
+ # (chunk_size,) count of how many actions are in the ensemble for each time step in the sequence.
+ self.ensembled_actions_count = None
+
+ def update(self, actions):
+ """
+ Takes a (batch, chunk_size, action_dim) sequence of actions, update the temporal ensemble for all
+ time steps, and pop/return the next batch of actions in the sequence.
+ """
+ self.ensemble_weights = self.ensemble_weights.to(device=actions.device)
+ self.ensemble_weights_cumsum = self.ensemble_weights_cumsum.to(device=actions.device)
+ if self.ensembled_actions is None:
+ # Initializes `self._ensembled_action` to the sequence of actions predicted during the first
+ # time step of the episode.
+ self.ensembled_actions = actions.clone()
+ # Note: The last dimension is unsqueeze to make sure we can broadcast properly for tensor
+ # operations later.
+ self.ensembled_actions_count = torch.ones(
+ (self.chunk_size, 1), dtype=torch.long, device=self.ensembled_actions.device
+ )
+ else:
+ # self.ensembled_actions will have shape (batch_size, chunk_size - 1, action_dim). Compute
+ # the online update for those entries.
+ self.ensembled_actions *= self.ensemble_weights_cumsum[self.ensembled_actions_count - 1]
+ self.ensembled_actions += (
+ actions[:, : -self.exe_steps] * self.ensemble_weights[self.ensembled_actions_count]
+ )
+ self.ensembled_actions /= self.ensemble_weights_cumsum[self.ensembled_actions_count]
+ self.ensembled_actions_count = torch.clamp(self.ensembled_actions_count + 1, max=self.chunk_size)
+ # The last action, which has no prior online average, needs to get concatenated onto the end.
+ self.ensembled_actions = torch.cat([self.ensembled_actions, actions[:, -self.exe_steps :]], dim=1)
+ self.ensembled_actions_count = torch.cat(
+ # [self.ensembled_actions_count, torch.ones_like(self.ensembled_actions_count[-self.exe_steps:])]
+ [
+ self.ensembled_actions_count,
+ torch.ones((self.exe_steps, 1), dtype=torch.long, device=self.ensembled_actions_count.device),
+ ]
+ )
+ # "Consume" the first action.
+
+ actions, self.ensembled_actions, self.ensembled_actions_count = (
+ self.ensembled_actions[:, : self.exe_steps],
+ self.ensembled_actions[:, self.exe_steps :],
+ self.ensembled_actions_count[self.exe_steps :],
+ )
+ return actions
+
+
+@dataclass
+class VideoFrame:
+ """
+ Provides a type for a dataset containing video frames.
+
+ Example:
+
+ ```python
+ data_dict = [{"image": {"path": "videos/episode_0.mp4", "timestamp": 0.3}}]
+ features = {"image": VideoFrame()}
+ Dataset.from_dict(data_dict, features=Features(features))
+ ```
+ """
+
+ pa_type: ClassVar[Any] = pa.struct({"path": pa.string(), "timestamp": pa.float32()})
+ _type: str = field(default="VideoFrame", init=False, repr=False)
+
+ def __call__(self):
+ return self.pa_type
+
+
+with warnings.catch_warnings():
+ warnings.filterwarnings(
+ "ignore",
+ "'register_feature' is experimental and might be subject to breaking changes in the future.",
+ category=UserWarning,
+ )
+ # to make VideoFrame available in HuggingFace `datasets`
+ register_feature(VideoFrame, "VideoFrame")
+
+
+def get_image(cam_list, target_shape=None, save_image=False):
+ curr_images = []
+ for cam in cam_list:
+ color, _ = cam.get_frame()
+ if save_image:
+ cv2.imwrite("/home/world-model-x/output.png", color)
+ color = cv2.cvtColor(color, cv2.COLOR_BGR2RGB)
+ if target_shape:
+ color = cv2.resize(color, target_shape)
+ curr_images.append(color)
+ curr_images = np.stack(curr_images, axis=0)
+ return curr_images
+
+
+def load_action_from_dataset(dataset_dir, episode_id):
+ data = load_from_disk(dataset_dir + "/train")
+ episode_data = load_file(dataset_dir + "/meta_data/episode_data_index.safetensors")
+ start_id = episode_data["from"][episode_id]
+ end_id = episode_data["to"][episode_id]
+ actions = torch.FloatTensor(data["action"][start_id:end_id])
+ return actions
+
+
+def load_stats_from_prompt_dir(dataset_dir, prompt_dir, subdir=""):
+ dataset_dir += subdir + "/meta_data"
+ stats = load_file(dataset_dir + "/stats.safetensors")
+ return stats
+
+
+def populate_queues(queues, batch):
+ for key in batch:
+ # Ignore keys not in the queues already (leaving the responsibility to the caller to make sure the
+ # queues have the keys they want).
+ if key not in queues:
+ continue
+ if len(queues[key]) != queues[key].maxlen:
+ # initialize by copying the first observation several times until the queue is full
+ while len(queues[key]) != queues[key].maxlen:
+ queues[key].append(batch[key])
+ else:
+ # add latest observation to the queue
+ queues[key].append(batch[key])
+ return queues
+
+
+def action_safe_checking(action, action_max, action_min, threshold=0.01):
+ over_max = any(action - threshold > action_max.cpu().numpy())
+ over_min = any(action + threshold < action_min.cpu().numpy())
+ return not (over_max or over_min)
+
+
+def get_init_pose(dataset_dir, start_id=0):
+ # load all par
+ dataset_dir_path = Path(dataset_dir) / "data" / "chunk-000"
+ parquet_files = list(dataset_dir_path.glob("*.parquet"))
+ parquet_files = sorted([str(f) for f in parquet_files])
+ first_rows = [pd.read_parquet(f, engine="pyarrow").iloc[[0]] for f in parquet_files]
+ df = pd.concat(first_rows, ignore_index=True)
+ action_array = np.stack(df["action"].values)
+ init_pose = action_array[192:193, ...]
+ return init_pose
+
+
+def save_image(obs, num_step=None, output_dir=None):
+ rgb_image = cv2.cvtColor(obs.observation["images"]["cam_left_high"], cv2.COLOR_BGR2RGB)
+ cv2.imwrite(f"{output_dir}/top_{num_step:06d}.png", rgb_image)
+
+
+def log_to_tensorboard(writer, data, tag, fps=10):
+ if isinstance(data, torch.Tensor) and data.dim() == 5:
+ video = data
+ n = video.shape[0]
+ video = video.permute(2, 0, 1, 3, 4) # t,n,c,h,w
+ frame_grids = [
+ torchvision.utils.make_grid(framesheet, nrow=int(n), padding=0) for framesheet in video
+ ] # [3, n*h, 1*w]
+ grid = torch.stack(frame_grids, dim=0) # stack in temporal dim [t, 3, n*h, w]
+ grid = (grid + 1.0) / 2.0
+ grid = grid.unsqueeze(dim=0)
+ writer.add_video(tag, grid, fps=fps)
diff --git a/unitree_deploy/unitree_deploy/utils/joint_trajcetory_inter.py b/unitree_deploy/unitree_deploy/utils/joint_trajcetory_inter.py
new file mode 100644
index 0000000..fcd2d3f
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/joint_trajcetory_inter.py
@@ -0,0 +1,196 @@
+"""The modification is derived from diffusion_policy/common/pose_trajectory_interpolator.py. Thank you for the outstanding contribution."""
+
+import numbers
+from typing import Union
+
+import numpy as np
+import scipy.interpolate as si
+
+
+def joint_pose_distance(start_joint_angles, end_joint_angles):
+ start_joint_angles = np.array(start_joint_angles)
+ end_joint_angles = np.array(end_joint_angles)
+ joint_angle_dist = np.linalg.norm(end_joint_angles - start_joint_angles)
+
+ return joint_angle_dist
+
+
+class JointTrajectoryInterpolator:
+ def __init__(self, times: np.ndarray, joint_positions: np.ndarray):
+ assert len(times) >= 1
+ assert len(joint_positions) == len(times)
+ self.num_joints = len(joint_positions[0])
+ if not isinstance(times, np.ndarray):
+ times = np.array(times)
+ if not isinstance(joint_positions, np.ndarray):
+ joint_positions = np.array(joint_positions)
+ if len(times) == 1:
+ self.single_step = True
+ self._times = times
+ self._joint_positions = joint_positions
+ else:
+ self.single_step = False
+ assert np.all(times[1:] >= times[:-1])
+ self.interpolators = si.interp1d(times, joint_positions, axis=0, assume_sorted=True)
+
+ @property
+ def times(self) -> np.ndarray:
+ if self.single_step:
+ return self._times
+ else:
+ return self.interpolators.x
+
+ @property
+ def joint_positions(self) -> np.ndarray:
+ if self.single_step:
+ return self._joint_positions
+ else:
+ n = len(self.times)
+ joint_positions = np.zeros((n, self.num_joints))
+ joint_positions = self.interpolators.y
+ return joint_positions
+
+ def trim(self, start_t: float, end_t: float) -> "JointTrajectoryInterpolator":
+ assert start_t <= end_t
+ times = self.times
+ should_keep = (start_t < times) & (times < end_t)
+ keep_times = times[should_keep]
+ all_times = np.concatenate([[start_t], keep_times, [end_t]])
+ all_times = np.unique(all_times)
+ all_joint_positions = self(all_times)
+ return JointTrajectoryInterpolator(times=all_times, joint_positions=all_joint_positions)
+
+ def drive_to_waypoint(
+ self,
+ pose,
+ time,
+ curr_time,
+ max_pos_speed=np.inf,
+ ) -> "JointTrajectoryInterpolator":
+ assert max_pos_speed > 0
+ time = max(time, curr_time)
+
+ curr_pose = self(curr_time)
+ pos_dist = joint_pose_distance(curr_pose, pose)
+ pos_min_duration = pos_dist / max_pos_speed
+ duration = time - curr_time
+ duration = max(duration, pos_min_duration)
+ assert duration >= 0
+ last_waypoint_time = curr_time + duration
+
+ # insert new pose
+ trimmed_interp = self.trim(curr_time, curr_time)
+ times = np.append(trimmed_interp.times, [last_waypoint_time], axis=0)
+ poses = np.append(trimmed_interp.joint_positions, [pose], axis=0)
+
+ # create new interpolator
+ final_interp = JointTrajectoryInterpolator(times, poses)
+ return final_interp
+
+ def schedule_waypoint(
+ self, pose, time, max_pos_speed=np.inf, curr_time=None, last_waypoint_time=None
+ ) -> "JointTrajectoryInterpolator":
+ assert max_pos_speed > 0
+ if last_waypoint_time is not None:
+ assert curr_time is not None
+
+ # trim current interpolator to between curr_time and last_waypoint_time
+ start_time = self.times[0]
+ end_time = self.times[-1]
+ assert start_time <= end_time
+
+ if curr_time is not None:
+ if time <= curr_time:
+ # if insert time is earlier than current time
+ # no effect should be done to the interpolator
+ return self
+ # now, curr_time < time
+ start_time = max(curr_time, start_time)
+
+ if last_waypoint_time is not None:
+ # if last_waypoint_time is earlier than start_time
+ # use start_time
+ end_time = curr_time if time <= last_waypoint_time else max(last_waypoint_time, curr_time)
+ else:
+ end_time = curr_time
+
+ end_time = min(end_time, time)
+ start_time = min(start_time, end_time)
+
+ # end time should be the latest of all times except time after this we can assume order (proven by zhenjia, due to the 2 min operations)
+ # Constraints:
+ # start_time <= end_time <= time (proven by zhenjia)
+ # curr_time <= start_time (proven by zhenjia)
+ # curr_time <= time (proven by zhenjia)
+
+ assert start_time <= end_time
+ assert end_time <= time
+ if last_waypoint_time is not None:
+ if time <= last_waypoint_time:
+ assert end_time == curr_time
+ else:
+ assert end_time == max(last_waypoint_time, curr_time)
+
+ if curr_time is not None:
+ assert curr_time <= start_time
+ assert curr_time <= time
+
+ trimmed_interp = self.trim(start_time, end_time)
+
+ # determine speed
+ duration = time - end_time
+ end_pose = trimmed_interp(end_time)
+ pos_dist = joint_pose_distance(pose, end_pose)
+
+ joint_min_duration = pos_dist / max_pos_speed
+
+ duration = max(duration, joint_min_duration)
+ assert duration >= 0
+ last_waypoint_time = end_time + duration
+
+ # insert new pose
+ times = np.append(trimmed_interp.times, [last_waypoint_time], axis=0)
+ poses = np.append(trimmed_interp.joint_positions, [pose], axis=0)
+
+ # create new interpolator
+ final_interp = JointTrajectoryInterpolator(times, poses)
+ return final_interp
+
+ def __call__(self, t: Union[numbers.Number, np.ndarray]) -> np.ndarray:
+ is_single = False
+ if isinstance(t, numbers.Number):
+ is_single = True
+ t = np.array([t])
+
+ joint_positions = np.zeros((len(t), self.num_joints))
+
+ if self.single_step:
+ joint_positions[:] = self._joint_positions[0]
+ else:
+ start_time = self.times[0]
+ end_time = self.times[-1]
+ t = np.clip(t, start_time, end_time)
+ joint_positions[:, :] = self.interpolators(t)
+
+ if is_single:
+ joint_positions = joint_positions[0]
+ return joint_positions
+
+
+def generate_joint_positions(
+ num_rows: int, num_cols: int, start: float = 0.0, step: float = 0.1, row_offset: float = 0.1
+) -> np.ndarray:
+ base_row = np.arange(start, start + step * num_cols, step)
+ array = np.vstack([base_row + i * row_offset for i in range(num_rows)])
+ return array
+
+
+if __name__ == "__main__":
+ # Example joint trajectory data (time in seconds, joint positions as an array of NUM_JOINTS joint angles)
+ times = np.array([0.0, 1.0, 2.0, 3.0, 4.0])
+ joint_positions = generate_joint_positions(num_rows=5, num_cols=7, start=0.0, step=0.1, row_offset=0.1)
+ interpolator = JointTrajectoryInterpolator(times, joint_positions)
+ # Get joint positions at a specific time (e.g., t = 2.5 seconds)
+ t = 0.1
+ joint_pos_at_t = interpolator(t)
+ print("Joint positions at time", t, ":", joint_pos_at_t)
diff --git a/unitree_deploy/unitree_deploy/utils/rerun_visualizer.py b/unitree_deploy/unitree_deploy/utils/rerun_visualizer.py
new file mode 100644
index 0000000..00fdf20
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/rerun_visualizer.py
@@ -0,0 +1,175 @@
+from datetime import datetime
+from typing import Any, Dict, Optional, Tuple
+
+import rerun as rr
+import rerun.blueprint as rrb
+import torch
+
+
+class RerunLogger:
+ """
+ A fully automatic Rerun logger designed to parse and visualize step
+ dictionaries directly from a LeRobotDataset.
+ """
+
+ def __init__(
+ self,
+ prefix: str = "",
+ memory_limit: str = "200MB",
+ idxrangeboundary: Optional[int] = 300,
+ ):
+ """Initializes the Rerun logger."""
+ # Use a descriptive name for the Rerun recording
+ rr.init(f"Dataset_Log_{datetime.now().strftime('%Y%m%d_%H%M%S')}")
+ rr.spawn(memory_limit=memory_limit)
+
+ self.prefix = prefix
+ self.blueprint_sent = False
+ self.idxrangeboundary = idxrangeboundary
+
+ # --- Internal cache for discovered keys ---
+ self._image_keys: Tuple[str, ...] = ()
+ self._state_key: str = ""
+ self._action_key: str = ""
+ self._index_key: str = "index"
+ self._task_key: str = "task"
+ self._episode_index_key: str = "episode_index"
+
+ self.current_episode = -1
+
+ def _initialize_from_data(self, step_data: Dict[str, Any]):
+ """Inspects the first data dictionary to discover components and set up the blueprint."""
+ print("RerunLogger: First data packet received. Auto-configuring...")
+
+ image_keys = []
+ for key, value in step_data.items():
+ if key.startswith("observation.images.") and isinstance(value, torch.Tensor) and value.ndim > 2:
+ image_keys.append(key)
+ elif key == "observation.state":
+ self._state_key = key
+ elif key == "action":
+ self._action_key = key
+
+ self._image_keys = tuple(sorted(image_keys))
+
+ if "index" in step_data:
+ self._index_key = "index"
+ elif "frame_index" in step_data:
+ self._index_key = "frame_index"
+
+ print(f" - Using '{self._index_key}' for time sequence.")
+ print(f" - Detected State Key: '{self._state_key}'")
+ print(f" - Detected Action Key: '{self._action_key}'")
+ print(f" - Detected Image Keys: {self._image_keys}")
+ if self.idxrangeboundary:
+ self.setup_blueprint()
+
+ def setup_blueprint(self):
+ """Sets up and sends the Rerun blueprint based on detected components."""
+ views = []
+
+ for key in self._image_keys:
+ clean_name = key.replace("observation.images.", "")
+ entity_path = f"{self.prefix}images/{clean_name}"
+ views.append(rrb.Spatial2DView(origin=entity_path, name=clean_name))
+
+ if self._state_key:
+ entity_path = f"{self.prefix}state"
+ views.append(
+ rrb.TimeSeriesView(
+ origin=entity_path,
+ name="Observation State",
+ time_ranges=[
+ rrb.VisibleTimeRange(
+ "frame",
+ start=rrb.TimeRangeBoundary.cursor_relative(seq=-self.idxrangeboundary),
+ end=rrb.TimeRangeBoundary.cursor_relative(),
+ )
+ ],
+ plot_legend=rrb.PlotLegend(visible=True),
+ )
+ )
+
+ if self._action_key:
+ entity_path = f"{self.prefix}action"
+ views.append(
+ rrb.TimeSeriesView(
+ origin=entity_path,
+ name="Action",
+ time_ranges=[
+ rrb.VisibleTimeRange(
+ "frame",
+ start=rrb.TimeRangeBoundary.cursor_relative(seq=-self.idxrangeboundary),
+ end=rrb.TimeRangeBoundary.cursor_relative(),
+ )
+ ],
+ plot_legend=rrb.PlotLegend(visible=True),
+ )
+ )
+
+ if not views:
+ print("Warning: No visualizable components detected in the data.")
+ return
+
+ grid = rrb.Grid(contents=views)
+ rr.send_blueprint(grid)
+ self.blueprint_sent = True
+
+ def log_step(self, step_data: Dict[str, Any]):
+ """Logs a single step dictionary from your dataset."""
+ if not self.blueprint_sent:
+ self._initialize_from_data(step_data)
+
+ if self._index_key in step_data:
+ current_index = step_data[self._index_key].item()
+ rr.set_time_sequence("frame", current_index)
+
+ episode_idx = step_data.get(self._episode_index_key, torch.tensor(-1)).item()
+ if episode_idx != self.current_episode:
+ self.current_episode = episode_idx
+ task_name = step_data.get(self._task_key, "Unknown Task")
+ log_text = f"Starting Episode {self.current_episode}: {task_name}"
+ rr.log(f"{self.prefix}info/task", rr.TextLog(log_text, level=rr.TextLogLevel.INFO))
+
+ for key in self._image_keys:
+ if key in step_data:
+ image_tensor = step_data[key]
+ if image_tensor.ndim > 2:
+ clean_name = key.replace("observation.images.", "")
+ entity_path = f"{self.prefix}images/{clean_name}"
+ if image_tensor.shape[0] in [1, 3, 4]:
+ image_tensor = image_tensor.permute(1, 2, 0)
+ rr.log(entity_path, rr.Image(image_tensor))
+
+ if self._state_key in step_data:
+ state_tensor = step_data[self._state_key]
+ entity_path = f"{self.prefix}state"
+ for i, val in enumerate(state_tensor):
+ rr.log(f"{entity_path}/joint_{i}", rr.Scalar(val.item()))
+
+ if self._action_key in step_data:
+ action_tensor = step_data[self._action_key]
+ entity_path = f"{self.prefix}action"
+ for i, val in enumerate(action_tensor):
+ rr.log(f"{entity_path}/joint_{i}", rr.Scalar(val.item()))
+
+
+def visualization_data(idx, observation, state, action, online_logger):
+ item_data: Dict[str, Any] = {
+ "index": torch.tensor(idx),
+ "observation.state": state,
+ "action": action,
+ }
+ for k, v in observation.items():
+ if k not in ("index", "observation.state", "action"):
+ item_data[k] = v
+ # print(item_data)
+ online_logger.log_step(item_data)
+
+
+def flatten_images(obs: dict) -> dict:
+ flat = {}
+ if "images" in obs:
+ for k, v in obs["images"].items():
+ flat[f"observation.images.{k}"] = torch.from_numpy(v)
+ return flat
diff --git a/unitree_deploy/unitree_deploy/utils/rich_logger.py b/unitree_deploy/unitree_deploy/utils/rich_logger.py
new file mode 100644
index 0000000..10188a9
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/rich_logger.py
@@ -0,0 +1,180 @@
+import time
+
+from rich.console import Console
+from rich.progress import (
+ BarColumn,
+ Progress,
+ SpinnerColumn,
+ TextColumn,
+ TimeElapsedColumn,
+)
+from rich.text import Text
+
+
+class RichLogger:
+ def __init__(self, level: str = "INFO"):
+ # Initialize the console for rich output
+ self.console = Console()
+
+ # Define log levels with corresponding priority
+ self.levels = {
+ "DEBUG": 0, # Lowest level, all logs are displayed
+ "INFO": 1, # Standard level, displays Info and higher
+ "SUCCESS": 2, # Displays success and higher priority logs
+ "WARNING": 3, # Displays warnings and errors
+ "ERROR": 4, # Highest level, only errors are shown
+ }
+
+ # Set default log level, use INFO if the level is invalid
+ self.level = self.levels.get(level.upper(), 1)
+
+ def _log(self, level: str, message: str, style: str, emoji=None):
+ # Check if the current log level allows this message to be printed
+ if self.levels[level] < self.levels["INFO"]:
+ return
+
+ # Format the timestamp
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
+
+ # Create a styled message
+ text = Text(f"[{timestamp}] [{level}] {message}", style=style)
+
+ # Print the message to the console
+ self.console.print(text)
+
+ def _log(self, level: str, message: str, style: str, emoji: str = None):
+ # Check if the current log level allows this message to be printed
+ if self.levels[level] < self.levels["INFO"]:
+ return
+
+ # Format the timestamp
+ timestamp = time.strftime("%Y-%m-%d %H:%M:%S")
+
+ # If emoji is provided, prepend it to the message
+ if emoji:
+ message = f"{emoji} {message}"
+
+ # Create a styled message
+ text = Text(f"[{timestamp}] [{level}] {message}", style=style)
+
+ # Print the message to the console
+ self.console.print(text)
+
+ # Basic log methods
+ def info(self, message: str, emoji: str | None = None):
+ # If the level is INFO or higher, print info log
+ if self.levels["INFO"] >= self.level:
+ self._log("INFO", message, "bold cyan", emoji)
+
+ def warning(self, message: str, emoji: str = "β οΈ"):
+ # If the level is WARNING or higher, print warning log
+ if self.levels["WARNING"] >= self.level:
+ self._log("WARNING", message, "bold yellow", emoji)
+
+ def error(self, message: str, emoji: str = "β"):
+ # If the level is ERROR or higher, print error log
+ if self.levels["ERROR"] >= self.level:
+ self._log("ERROR", message, "bold red", emoji)
+
+ def success(self, message: str, emoji: str = "π"):
+ # If the level is SUCCESS or higher, print success log
+ if self.levels["SUCCESS"] >= self.level:
+ self._log("SUCCESS", message, "bold green", emoji)
+
+ def debug(self, message: str, emoji: str = "π"):
+ # If the level is DEBUG or higher, print debug log
+ if self.levels["DEBUG"] >= self.level:
+ self._log("DEBUG", message, "dim", emoji)
+
+ # ========== Extended Features ==========
+ # Display a message with an emoji
+ def emoji(self, message: str, emoji: str = "π"):
+ self.console.print(f"{emoji} {message}", style="bold magenta")
+
+ # Show a loading animation for a certain period
+ def loading(self, message: str, seconds: float = 2.0):
+ # Display a loading message with a spinner animation
+ with self.console.status(f"[bold blue]{message}...", spinner="dots"):
+ time.sleep(seconds)
+
+ # Show a progress bar for small tasks
+ def progress(self, task_description: str, total: int = 100, speed: float = 0.02):
+ # Create and display a progress bar with time elapsed
+ with Progress(
+ SpinnerColumn(),
+ BarColumn(bar_width=None),
+ TextColumn("[progress.percentage]{task.percentage:>3.0f}%"),
+ TimeElapsedColumn(),
+ console=self.console,
+ ) as progress:
+ # Add a task to the progress bar
+ task = progress.add_task(f"[cyan]{task_description}", total=total)
+ while not progress.finished:
+ progress.update(task, advance=1)
+ time.sleep(speed)
+
+
+# ========== Singleton Logger Instance ==========
+_logger = RichLogger()
+
+
+# ========== Function-style API ==========
+def log_info(message: str, emoji: str | None = None):
+ _logger.info(message=message, emoji=emoji)
+
+
+def log_success(message: str, emoji: str = "π"):
+ _logger.success(message=message, emoji=emoji)
+
+
+def log_warning(message: str, emoji: str = "β οΈ"):
+ _logger.warning(message=message, emoji=emoji)
+
+
+def log_error(message: str, emoji: str = "β"):
+ _logger.error(message=message, emoji=emoji)
+
+
+def log_debug(message: str, emoji: str = "π"):
+ _logger.debug(message=message, emoji=emoji)
+
+
+def log_emoji(message: str, emoji: str = "π"):
+ _logger.emoji(message, emoji)
+
+
+def log_loading(message: str, seconds: float = 2.0):
+ _logger.loading(message, seconds)
+
+
+def log_progress(task_description: str, total: int = 100, speed: float = 0.02):
+ _logger.progress(task_description, total, speed)
+
+
+if __name__ == "__main__":
+ # Example usage:
+ # Initialize logger instance
+ logger = RichLogger(level="INFO") # Set initial log level to INFO
+
+ # Log at different levels
+ logger.info("System initialization complete.")
+ logger.success("Robot started successfully!")
+ logger.warning("Warning: Joint temperature high!")
+ logger.error("Error: Failed to connect to robot")
+ logger.debug("Debug: Initializing motor controllers")
+
+ # Display an emoji message
+ logger.emoji("This is a fun message with an emoji!", emoji="π₯")
+
+ # Display loading animation for 3 seconds
+ logger.loading("Loading motor control data...", seconds=3)
+
+ # Show progress bar for a task with 100 steps
+ logger.progress("Processing task", total=100, speed=0.05)
+
+ # You can also use different log levels with a higher level than INFO, like ERROR:
+ logger = RichLogger(level="ERROR")
+
+ # Only error and higher priority logs will be shown (INFO, SUCCESS, WARNING will be hidden)
+ logger.info("This won't be displayed because the level is set to ERROR")
+ logger.error("This error will be displayed!")
diff --git a/unitree_deploy/unitree_deploy/utils/run_simulation.py b/unitree_deploy/unitree_deploy/utils/run_simulation.py
new file mode 100644
index 0000000..6454882
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/run_simulation.py
@@ -0,0 +1,171 @@
+import time
+from dataclasses import dataclass
+from multiprocessing import Process, Queue
+from queue import Empty
+
+import mujoco
+import mujoco.viewer
+import numpy as np
+
+from unitree_deploy.utils.rich_logger import log_info, log_success
+
+
+@dataclass
+class MujocoSimulationConfig:
+ xml_path: str
+ dof: int
+ robot_type: str
+ ctr_dof: int
+ stop_dof: int
+
+
+def get_mujoco_sim_config(robot_type: str) -> MujocoSimulationConfig:
+ if robot_type == "g1":
+ return MujocoSimulationConfig(
+ xml_path="unitree_deploy/robot_devices/assets/g1/g1_body29.xml",
+ dof=30,
+ robot_type="g1",
+ ctr_dof=14,
+ stop_dof=35,
+ )
+ elif robot_type == "z1":
+ return MujocoSimulationConfig(
+ xml_path="unitree_deploy/robot_devices/assets/z1/z1.xml",
+ dof=6,
+ robot_type="z1",
+ ctr_dof=6,
+ stop_dof=6,
+ )
+ elif robot_type == "h1_2":
+ return MujocoSimulationConfig(
+ xml_path="unitree_deploy/robot_devices/assets/z1/z1.urdf",
+ dof=30,
+ robot_type="g1",
+ ctr_dof=14,
+ stop_dof=35,
+ )
+ else:
+ raise ValueError(f"Unsupported robot_type: {robot_type}")
+
+
+class MujicoSimulation:
+ def __init__(self, config: MujocoSimulationConfig):
+ self.xml_path = config.xml_path
+
+ self.robot_type = config.robot_type
+
+ self.dof = config.dof
+ self.ctr_dof = config.ctr_dof
+ self.stop_dof = config.stop_dof
+
+ self.action_queue = Queue()
+ self.state_queue = Queue()
+ self.process = Process(target=self._run_simulation, args=(self.xml_path, self.action_queue, self.state_queue))
+ self.process.daemon = True
+ self.process.start()
+
+ def set_positions(self, joint_positions: np.ndarray):
+ if joint_positions.shape[0] != self.ctr_dof:
+ raise ValueError(f"joint_positions must contain {self.ctr_dof} values!")
+
+ if self.robot_type == "g1":
+ joint_positions = np.concatenate([np.zeros(self.dof - self.ctr_dof, dtype=np.float32), joint_positions])
+ elif self.robot_type == "z1":
+ pass
+ elif self.robot_type == "h1_2":
+ joint_positions[: self.dof - self.ctr_dof] = 0.0
+ else:
+ raise ValueError(f"Unsupported robot_type: {self.robot_type}")
+
+ self.action_queue.put(joint_positions.tolist())
+
+ def get_current_positions(self, timeout=0.01):
+ try:
+ return self.state_queue.get(timeout=timeout)
+ except Empty:
+ return [0.0] * self.stop_dof
+
+ def stop(self):
+ if hasattr(self, "process") and self.process is not None and self.process.is_alive():
+ try:
+ self.process.terminate()
+ self.process.join()
+ except Exception as e:
+ print(f"[WARN] Failed to stop process: {e}")
+ self.process = None
+
+ for qname in ["action_queue", "state_queue"]:
+ queue = getattr(self, qname, None)
+ if queue is not None:
+ try:
+ if hasattr(queue, "close") and callable(queue.close):
+ queue.close()
+ if hasattr(queue, "join_thread") and callable(queue.join_thread):
+ queue.join_thread()
+ except Exception as e:
+ print(f"[WARN] Failed to cleanup {qname}: {e}")
+ setattr(self, qname, None)
+
+ def __del__(self):
+ self.stop()
+
+ @staticmethod
+ def _run_simulation(xml_path: str, action_queue: Queue, state_queue: Queue):
+ model = mujoco.MjModel.from_xml_path(xml_path)
+ data = mujoco.MjData(model)
+
+ joint_names = [mujoco.mj_id2name(model, mujoco.mjtObj.mjOBJ_JOINT, i) for i in range(model.njnt)]
+ joints_indices = [
+ model.jnt_qposadr[mujoco.mj_name2id(model, mujoco.mjtObj.mjOBJ_JOINT, name)] for name in joint_names
+ ]
+ log_info(f"len joints indices: {len(joints_indices)}")
+
+ viewer = mujoco.viewer.launch_passive(model, data)
+
+ current_positions = np.zeros(len(joints_indices), dtype=np.float32)
+ try:
+ while viewer.is_running():
+ try:
+ new_pos = action_queue.get_nowait()
+ if len(new_pos) == len(joints_indices):
+ current_positions = new_pos
+ except Empty:
+ pass
+
+ for idx, pos in zip(joints_indices, current_positions, strict=True):
+ data.qpos[idx] = pos
+
+ data.qvel[:] = 0
+ mujoco.mj_forward(model, data)
+
+ state_queue.put(data.qpos.copy())
+
+ viewer.sync()
+ time.sleep(0.001)
+
+ except KeyboardInterrupt:
+ log_success("The simulation process was interrupted.")
+ finally:
+ viewer.close()
+
+
+def main():
+ config = get_mujoco_sim_config(robot_type="g1")
+ sim = MujicoSimulation(config)
+ time.sleep(1) # Allow time for the simulation to start
+ try:
+ while True:
+ positions = np.random.uniform(-1.0, 1.0, sim.ctr_dof)
+
+ sim.set_positions(positions)
+
+ # print(sim.get_current_positions())
+
+ time.sleep(1 / 50)
+ except KeyboardInterrupt:
+ print("Simulation stopped.")
+ sim.stop()
+
+
+if __name__ == "__main__":
+ main()
diff --git a/unitree_deploy/unitree_deploy/utils/trajectory_generator.py b/unitree_deploy/unitree_deploy/utils/trajectory_generator.py
new file mode 100644
index 0000000..ea3958d
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/trajectory_generator.py
@@ -0,0 +1,36 @@
+import math
+
+import numpy as np
+import pinocchio as pin
+
+
+def generate_rotation(step: int, rotation_speed: float, max_step: int = 240):
+ """Generate rotation (quaternions) and translation deltas for left and right arm motions."""
+ angle = rotation_speed * step if step <= max_step // 2 else rotation_speed * (max_step - step)
+
+ # Create rotation quaternion for left arm (around Y-axis)
+ l_quat = pin.Quaternion(np.cos(angle / 2), 0, np.sin(angle / 2), 0)
+
+ # Create rotation quaternion for right arm (around Z-axis)
+ r_quat = pin.Quaternion(np.cos(angle / 2), 0, 0, np.sin(angle / 2))
+
+ # Define translation increments for left and right arm
+ delta_l = np.array([0.001, 0.001, 0.001]) * 1.2
+ delta_r = np.array([0.001, -0.001, 0.001]) * 1.2
+
+ # Reverse direction in second half of cycle
+ if step > max_step // 2:
+ delta_l *= -1
+ delta_r *= -1
+
+ return l_quat, r_quat, delta_l, delta_r
+
+
+def sinusoidal_single_gripper_motion(period: float, amplitude: float, current_time: float) -> np.ndarray:
+ value = amplitude * (math.sin(2 * math.pi * current_time / period) + 1) / 2
+ return np.array([value*5])
+
+
+def sinusoidal_gripper_motion(period: float, amplitude: float, current_time: float) -> np.ndarray:
+ value = amplitude * (math.sin(2 * math.pi * current_time / period) + 1) / 2
+ return np.array([value]*5)
diff --git a/unitree_deploy/unitree_deploy/utils/weighted_moving_filter.py b/unitree_deploy/unitree_deploy/utils/weighted_moving_filter.py
new file mode 100644
index 0000000..e87ac95
--- /dev/null
+++ b/unitree_deploy/unitree_deploy/utils/weighted_moving_filter.py
@@ -0,0 +1,40 @@
+import numpy as np
+
+
+class WeightedMovingFilter:
+ def __init__(self, weights, data_size=14):
+ self._window_size = len(weights)
+ self._weights = np.array(weights)
+ assert np.isclose(np.sum(self._weights), 1.0), (
+ "[WeightedMovingFilter] the sum of weights list must be 1.0!"
+ )
+ self._data_size = data_size
+ self._filtered_data = np.zeros(self._data_size)
+ self._data_queue = []
+
+ def _apply_filter(self):
+ if len(self._data_queue) < self._window_size:
+ return self._data_queue[-1]
+
+ data_array = np.array(self._data_queue)
+ temp_filtered_data = np.zeros(self._data_size)
+ for i in range(self._data_size):
+ temp_filtered_data[i] = np.convolve(data_array[:, i], self._weights, mode="valid")[-1]
+
+ return temp_filtered_data
+
+ def add_data(self, new_data):
+ assert len(new_data) == self._data_size
+
+ if len(self._data_queue) > 0 and np.array_equal(new_data, self._data_queue[-1]):
+ return # skip duplicate data
+
+ if len(self._data_queue) >= self._window_size:
+ self._data_queue.pop(0)
+
+ self._data_queue.append(new_data)
+ self._filtered_data = self._apply_filter()
+
+ @property
+ def filtered_data(self):
+ return self._filtered_data