Timsty's picture
Add files using upload-large-folder tool
87c8483 verified
Raw History Blame Contribute Delete
99.3 kB
from typing import TYPE_CHECKING # noqa: I001
import numpy as np
from omegaconf import OmegaConf
import omni
from pxr import Gf, UsdGeom
import torch
from loguru import logger
from gear_sonic.trl.utils.common import custom_instantiate
if TYPE_CHECKING:
from isaaclab.envs.manager_based_rl_env import ManagerBasedEnv
# Import joint index functions (single source of truth)
from gear_sonic.envs.env_utils.joint_utils import get_body_joint_indices, get_hand_joint_indices
# Import visualization markers for contact point visualization
try:
from isaaclab.markers import VisualizationMarkers, VisualizationMarkersCfg
import isaaclab.sim as sim_utils
VISUALIZATION_AVAILABLE = True
except ImportError:
VISUALIZATION_AVAILABLE = False
class ManagerEnvWrapper:
def __init__(self, env: "ManagerBasedEnv", config):
env.wrapper = self
self.env = env
self.config = OmegaConf.create(config)
self.device = env.device
self.viewer_focused = True
self.is_manager_env = True
if hasattr(self.env, "num_envs"):
self.num_envs = self.env.num_envs
else:
self.num_envs = self.env.env.unwrapped.num_envs
try:
self.motion_command = env.command_manager.get_term("motion")
self._motion_lib = self.motion_command.motion_lib
except: # noqa: E722
logger.info("No motion lib found")
self.motion_command = None
self._motion_lib = None
try:
self.force_command = env.command_manager.get_term("force")
except: # noqa: E722
self.force_command = None
self.is_evaluating = False
self.start_idx = 0
self._last_predicted_object_pos = (
None # For debug visualization of predicted object position
)
if not self.config.headless:
self.setup_keyboard()
# Action visualization toggle and state
self.turn_on_visualization = bool(self.config.get("turn_on_visualization", False)) and (
not self.config.get("headless", False)
)
self._viz_every_n_steps = int(self.config.get("viz_every_n_steps", 1))
self._plot_action_dim = int(self.config.get("action_plot_dim", 29))
clip_default = self.config.get("action_clip_value", 1.0) # noqa: F841
self._action_ylim = float(self.config.get("action_plot_ylim", 10.0))
self._plot_window = int(self.config.get("action_plot_window", 200))
self._step_counter = 0
self._action_fig = None
self._action_lines = None
self._action_hist = None
self._hist_idx = 0
self._blit_background = None
self._blit_supported = True
self._blit_refresh_interval = int(self.config.get("plot_blit_refresh_interval", 120))
# Initialize action transform module from config
self.action_transform_module = None
self._needs_policy_atm = False # Default: use policy obs directly
self._policy_atm_indices = None
# Initialize joint index mappings (for DOF mismatch handling in replay and step)
self._body_joint_indices = None
self._hand_joint_indices = None
self._setup_replay_joint_indices() # Setup for replay mode
# Initialize finger primitive support
self._use_finger_primitive = self.config.get("use_finger_primitive", False)
self._finger_primitive_map = None
if self._use_finger_primitive:
self._setup_finger_primitives()
# Latent residual mode: policy outputs residual added to token latent space
self._use_latent_residual = self.config.get("use_latent_residual", False)
# Latent residual options (only used when use_latent_residual=True)
self._latent_residual_mode = self.config.get("latent_residual_mode", "post_quantization")
self._latent_residual_scale = self.config.get("latent_residual_scale", 1.0)
# Student direct latent mode: policy outputs FULL latent (not residual)
# This is used for vision student policies that learned to output full latent
# directly, bypassing the ATM encoder entirely at inference time.
# The 64-dim output goes directly to ATM decoder (no encoding step).
self._use_student_direct_latent = self.config.get("use_student_direct_latent", False)
if self._use_latent_residual:
logger.info(
f"Latent residual enabled: mode={self._latent_residual_mode}, "
f"scale={self._latent_residual_scale}"
)
if self._use_student_direct_latent:
logger.info(
"Student direct latent mode enabled: policy output goes directly to ATM decoder "
"(no encoding step)"
)
# Camera extrinsics randomization state
self._camera_extrinsics_randomized = False
self._camera_base_transforms = {} # Store original transforms per env
action_transform_module_cfg = self.config.get("action_transform_module_cfg", None)
if action_transform_module_cfg is not None:
# Load configs from exported YAML file
with open(action_transform_module_cfg) as f:
exported_config = OmegaConf.load(f)
env_config = exported_config.get("env_config", {})
algo_config = exported_config.get("algo_config", {})
self.action_transform_module = custom_instantiate(
algo_config.actor, env_config=env_config, algo_config=algo_config, _resolve=False
).to(self.device)
logger.info(f"Initialized action_transform_module from config: {action_transform_module_cfg}")
# Load checkpoint if provided
action_transform_module_checkpoint = self.config.get(
"action_transform_module_checkpoint", None
)
if action_transform_module_checkpoint is not None:
# Compatibility shim: checkpoints saved with TRL < 0.28.0 reference
# trl.trainer.utils.OnlineTrainerState, which was moved in 0.28.0
try:
from trl.experimental.ppo.ppo_trainer import OnlineTrainerState, exact_div
import trl.trainer.utils
trl.trainer.utils.OnlineTrainerState = OnlineTrainerState
trl.trainer.utils.exact_div = exact_div
except ImportError:
pass
checkpoint = torch.load(
action_transform_module_checkpoint, map_location=self.device, weights_only=False
)
self.action_transform_module.load_state_dict(checkpoint["policy_state_dict"])
logger.info(
f"Loaded action_transform_module checkpoint: {action_transform_module_checkpoint}"
)
# Precompute tokenizer observation indices for meta_action target
self._tokenizer_obs_indices = self._compute_tokenizer_obs_indices()
# Setup policy_atm observations for action_transform_module if DOF mismatch exists
self._setup_policy_atm(env_config, algo_config)
try:
self.viewer_focused = True
self.env.viewport_camera_controller.update_view_to_world()
self.env.viewport_camera_controller.update_view_to_asset_root("robot")
except: # noqa: E722
self.viewer_focused = False
def _setup_replay_joint_indices(self):
"""Setup joint indices for replay mode if robot has more DOFs than motion lib (29)."""
if self.env.scene["robot"].num_joints > 29:
self._setup_action_joint_indices()
def _compute_tokenizer_obs_indices(self):
"""Compute start and end indices for each tokenizer observation in the flattened tensor."""
if self.action_transform_module is None:
return {}
tokenizer_obs_names = self.action_transform_module.actor_module.tokenizer_obs_names
tokenizer_obs_dims = self.action_transform_module.actor_module.tokenizer_obs_dims
indices = {}
current_index = 0
for name in tokenizer_obs_names:
all_dim = int(np.prod(tokenizer_obs_dims[name]))
indices[name] = (current_index, current_index + all_dim)
current_index += all_dim
return indices
def _setup_policy_atm(self, env_config, algo_config): # noqa: ARG002
"""Setup policy_atm for action_transform_module when robot has more DOFs than ATM expects."""
atm_num_joints = env_config.get("robot", {}).get("actions_dim") or env_config.get(
"robot", {}
).get("num_joints", 29)
self._needs_policy_atm = self.config.get("needs_policy_atm", True)
self._atm_num_joints = atm_num_joints
self._current_num_joints = self.env.scene["robot"].num_joints
if self._current_num_joints > atm_num_joints:
assert (
self._needs_policy_atm
), "Robot has more DOFs than ATM expects, but needs_policy_atm is False"
has_policy_atm = (
hasattr(self.env, "observation_manager")
and "policy_atm" in self.env.observation_manager._group_obs_term_names # noqa: SLF001
)
self._use_policy_atm_group = self._needs_policy_atm and has_policy_atm
if self._use_policy_atm_group:
self._setup_action_joint_indices()
def _setup_action_joint_indices(self):
"""Compute joint indices for mapping body (29 DOF) and hand (14 DOF) actions."""
if self._body_joint_indices is not None:
return
robot = self.env.scene["robot"]
self._body_joint_indices = get_body_joint_indices(robot)
self._hand_joint_indices = get_hand_joint_indices(robot)
def _setup_finger_primitives(self):
"""Setup finger primitive action mapping from config.
Finger primitives allow the policy to output 2 actions (left/right gripper)
instead of 14 individual finger joint actions. Each primitive action
interpolates between open (pos_0) and closed (pos_1) positions.
"""
primitive_cfg = self.config.get("finger_primitive", {})
primitive_action_map = primitive_cfg.get("primitive_action_map", {})
if not primitive_action_map:
logger.info("Warning: use_finger_primitive=True but no primitive_action_map defined")
self._use_finger_primitive = False
return
robot = self.env.scene["robot"]
joint_names = robot.joint_names
self._finger_primitive_map = []
for action_name in sorted(primitive_action_map.keys()):
prim_cfg = primitive_action_map[action_name]
dof_names = list(prim_cfg.get("dof_names", []))
pos_0 = torch.tensor(prim_cfg.get("pos_0", []), device=self.device, dtype=torch.float32)
pos_1 = torch.tensor(prim_cfg.get("pos_1", []), device=self.device, dtype=torch.float32)
# Find indices in the hand joints
dof_idx = []
for dof_name in dof_names:
if dof_name in joint_names:
dof_idx.append(joint_names.index(dof_name))
else:
logger.info(f"Warning: DOF {dof_name} not found in robot joints")
# Support both "mode" (new) and "discrete" (legacy) config keys
mode = prim_cfg.get("mode", None)
if mode is None:
# Legacy support: discrete=True → mode="discrete", discrete=False → mode="linear"
mode = "discrete" if prim_cfg.get("discrete", False) else "linear"
self._finger_primitive_map.append(
{
"action_name": action_name,
"dof_names": dof_names,
"dof_idx": dof_idx,
"pos_0": pos_0,
"pos_1": pos_1,
"mode": mode,
}
)
logger.info(f"Finger primitive '{action_name}': {len(dof_names)} DOFs, mode={mode}")
self._num_finger_primitives = len(self._finger_primitive_map)
logger.info(f"Initialized {self._num_finger_primitives} finger primitives")
def _convert_primitive_to_finger_actions(self, primitive_actions: torch.Tensor) -> torch.Tensor:
"""Convert primitive actions (num_envs, num_primitives) to finger joint targets (num_envs, 14).
Follows groot_backup implementation:
- "linear" mode: maps [-1, 1] → [0, 1] via (x + 1) / 2, then lerps between pos_0 and pos_1
- "discrete" mode: action >= 0 → pos_1 (closed), action < 0 → pos_0 (open)
Args:
primitive_actions: Tensor of shape (num_envs, num_primitives), values in [-1, 1]
Returns:
Tensor of shape (num_envs, num_finger_joints) with joint position targets
"""
num_envs = primitive_actions.shape[0]
num_finger_joints = (
len(self._hand_joint_indices) if self._hand_joint_indices is not None else 14
)
finger_targets = torch.zeros(
num_envs, num_finger_joints, device=self.device, dtype=primitive_actions.dtype
)
for i, prim_cfg in enumerate(self._finger_primitive_map):
action = primitive_actions[:, i] # (num_envs,), values in [-1, 1]
p0 = prim_cfg["pos_0"]
p1 = prim_cfg["pos_1"]
dof_idx = prim_cfg["dof_idx"]
mode = prim_cfg.get("mode", "linear")
if mode == "linear":
# Clamp to [-1, 1] then map to [0, 1]
action_clamped = action.clamp(min=-1.0, max=1.0)
t = (action_clamped + 1.0) / 2.0 # (num_envs,)
joint_targets = torch.lerp(p0.unsqueeze(0), p1.unsqueeze(0), t.unsqueeze(1))
elif mode == "discrete":
# action >= 0 → closed (pos_1), action < 0 → open (pos_0)
joint_targets = torch.where(
action.unsqueeze(1) >= 0, p1.unsqueeze(0), p0.unsqueeze(0)
)
else:
raise ValueError(f"Invalid finger primitive mode: {mode}")
# Map to the correct indices in finger_targets
# dof_idx are absolute joint indices, need to convert to relative hand indices
for j, abs_idx in enumerate(dof_idx):
if self._hand_joint_indices is not None:
# Convert tensor to list if needed for .index() lookup
hand_indices_list = (
self._hand_joint_indices.tolist()
if isinstance(self._hand_joint_indices, torch.Tensor)
else self._hand_joint_indices
)
rel_idx = hand_indices_list.index(abs_idx)
finger_targets[:, rel_idx] = joint_targets[:, j]
else:
# Fallback: assume hand joints are at the end
finger_targets[:, abs_idx - 29] = joint_targets[:, j]
return finger_targets
def _prepare_obs_for_action_transform_module(self, obs_dict):
"""Use policy_atm observations for ATM if DOF mismatch exists, else use policy."""
if not self._use_policy_atm_group or "policy_atm" not in obs_dict:
atm_obs_dict = obs_dict.copy()
else:
atm_obs_dict = obs_dict.copy()
atm_obs_dict["actor_obs"] = atm_obs_dict["policy_atm"]
# Ensure all observations have a sequence dimension [num_envs, seq_len, dim]
# The action_transform_module expects 3D tensors
for k, v in atm_obs_dict.items():
if isinstance(v, torch.Tensor) and v.dim() == 2:
atm_obs_dict[k] = v.unsqueeze(1) # Add seq_len=1 dimension
return atm_obs_dict
def reset_all(self, global_rank=0): # noqa: ARG002
return self.reset()
def process_raw_obs(self, obs, flatten_dict_obs):
new_obs = {
"actor_obs": obs["policy"],
"critic_obs": obs["critic"],
}
for k, v in obs.items():
if k not in ["policy", "critic"]:
if isinstance(v, dict) and flatten_dict_obs:
if k == "height_map":
# Special case: do not flatten height map
new_obs[k] = v["height_map"]
continue
if k == "camera_rgb":
# Special case: do not flatten camera RGB image
# Keep original shape [B, H, W, C] for vision encoder
new_obs[k] = v["camera_rgb"]
continue
obs_names = self.env.observation_manager._group_obs_term_names[k] # noqa: SLF001
new_obs[k] = torch.cat(
[v[obs_name].reshape(v[obs_name].shape[0], -1) for obs_name in obs_names],
dim=-1,
)
else:
new_obs[k] = v
return new_obs
def reset(self, flatten_dict_obs=True):
obs, info = self.env.reset()
new_obs = self.process_raw_obs(obs, flatten_dict_obs)
# Initialize success_lift to False for all envs after reset (used unconditionally in step())
self.env.success_lift = torch.zeros(
self.env.num_envs, dtype=torch.bool, device=self.env.device
)
if self.action_transform_module is not None:
# Store obs for action_transform_module when obs_dict is not provided in step()
self._last_obs_dict = new_obs
# Initialize last meta action buffer (policy output: latent + primitives)
# meta_action_dim = tokenizer_action_dim + hand_action_dim (e.g., 64 + 2 = 66)
meta_action_dim = self.config.get("meta_action_dim", 66)
self.env._last_meta_action = torch.zeros( # noqa: SLF001
self.env.num_envs, meta_action_dim, dtype=torch.float32, device=self.env.device
)
# Previous meta action buffer for meta_action_rate_l2 reward (token smoothness)
self.env._prev_meta_action = torch.zeros( # noqa: SLF001
self.env.num_envs, meta_action_dim, dtype=torch.float32, device=self.env.device
)
# Full latent buffers for full_latent_rate_l2 reward (decoder input smoothness)
# tokenizer_action_dim = latent_dim (e.g., 64 = num_tokens * token_dim)
tokenizer_action_dim = self.config.get("tokenizer_action_dim", 64)
self.env._full_latent = torch.zeros( # noqa: SLF001
self.env.num_envs, tokenizer_action_dim, dtype=torch.float32, device=self.env.device
)
self.env._prev_full_latent = torch.zeros( # noqa: SLF001
self.env.num_envs, tokenizer_action_dim, dtype=torch.float32, device=self.env.device
)
# Apply camera extrinsics randomization on every reset
self.apply_random_camera_extrinsics()
return new_obs
def apply_random_camera_extrinsics(self):
"""Apply per-environment random camera extrinsics (position and rotation offsets).
Reads randomization ranges from config:
- cameras.camera_extrinsics_randomization: Enable/disable switch (default: False)
- cameras.camera_pos_rand_range: ±meters for x, y, z position
- cameras.camera_roll_rand_range: ±radians for roll
- cameras.camera_pitch_rand_range: ±radians for pitch
- cameras.camera_yaw_rand_range: ±radians for yaw
"""
cameras_config = self.config.get("cameras", {})
# Check if randomization is enabled via switch
if not cameras_config.get("camera_extrinsics_randomization", False):
return
# Get randomization ranges
pos_range = cameras_config.get("camera_pos_rand_range", 0.0)
roll_range = cameras_config.get("camera_roll_rand_range", 0.0)
pitch_range = cameras_config.get("camera_pitch_rand_range", 0.0)
yaw_range = cameras_config.get("camera_yaw_rand_range", 0.0)
# Check if any randomization values are non-zero
if pos_range == 0 and roll_range == 0 and pitch_range == 0 and yaw_range == 0:
return
# Get camera attached link from config
camera_attached_link = cameras_config.get("camera_attached_link", None)
if camera_attached_link is None:
logger.info("Skipping camera extrinsics randomization: no camera_attached_link configured")
return
# Only print details on first call
if not self._camera_extrinsics_randomized:
logger.info("Applying random camera extrinsics per environment:")
logger.info(f" Position range: ±{pos_range*100:.1f}cm")
logger.info(f" Roll range: ±{roll_range:.3f} rad ({np.degrees(roll_range):.1f}°)")
logger.info(f" Pitch range: ±{pitch_range:.3f} rad ({np.degrees(pitch_range):.1f}°)")
logger.info(f" Yaw range: ±{yaw_range:.3f} rad ({np.degrees(yaw_range):.1f}°)")
stage = omni.usd.get_context().get_stage()
for env_id in range(self.env.scene.num_envs):
camera_prim_path = f"/World/envs/env_{env_id}/Robot/{camera_attached_link}/ego_camera"
camera_prim = stage.GetPrimAtPath(camera_prim_path)
if not camera_prim.IsValid():
if env_id == 0:
logger.info(f" Warning: Camera prim not found at {camera_prim_path}")
continue
# Get current camera transform
xformable = UsdGeom.Xformable(camera_prim)
# On first call, store the original/base transforms
if env_id not in self._camera_base_transforms:
base_translate = None
base_orient = None
for op in xformable.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
base_translate = Gf.Vec3d(op.Get()) # Make a copy
elif op.GetOpType() == UsdGeom.XformOp.TypeOrient:
base_orient = Gf.Quatd(op.Get()) # Make a copy
self._camera_base_transforms[env_id] = {
"translate": base_translate,
"orient": base_orient,
}
# Get base transforms
base_translate = self._camera_base_transforms[env_id]["translate"]
base_orient = self._camera_base_transforms[env_id]["orient"]
# Sample random deltas
if pos_range > 0: # noqa: SIM108
pos_delta = np.random.uniform(-pos_range, pos_range, 3) # noqa: NPY002
else:
pos_delta = np.zeros(3)
# Sample rotation deltas (roll, pitch, yaw)
roll_delta = np.random.uniform(-roll_range, roll_range) if roll_range > 0 else 0.0 # noqa: NPY002
pitch_delta = np.random.uniform(-pitch_range, pitch_range) if pitch_range > 0 else 0.0 # noqa: NPY002
yaw_delta = np.random.uniform(-yaw_range, yaw_range) if yaw_range > 0 else 0.0 # noqa: NPY002
# Apply position delta relative to BASE (not current)
if base_translate is not None:
new_pos = Gf.Vec3d(
base_translate[0] + pos_delta[0],
base_translate[1] + pos_delta[1],
base_translate[2] + pos_delta[2],
)
# Find and update the translate op
for op in xformable.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeTranslate:
op.Set(new_pos)
break
# Apply rotation delta relative to BASE (not current)
if base_orient is not None and (roll_delta != 0 or pitch_delta != 0 or yaw_delta != 0):
# Convert euler deltas to quaternion
# Order: roll (X), pitch (Y), yaw (Z)
cr, sr = np.cos(roll_delta / 2), np.sin(roll_delta / 2)
cp, sp = np.cos(pitch_delta / 2), np.sin(pitch_delta / 2)
cy, sy = np.cos(yaw_delta / 2), np.sin(yaw_delta / 2)
# Quaternion from euler (ZYX convention)
delta_quat = Gf.Quatd(
cr * cp * cy + sr * sp * sy, # w
sr * cp * cy - cr * sp * sy, # x
cr * sp * cy + sr * cp * sy, # y
cr * cp * sy - sr * sp * cy, # z
)
# Compose: new = delta * BASE (not current!)
new_orient = delta_quat * base_orient
# Find and update the orient op
for op in xformable.GetOrderedXformOps():
if op.GetOpType() == UsdGeom.XformOp.TypeOrient:
op.Set(new_orient)
break
# Store the random deltas for each environment (for projection in observations)
if not hasattr(self, "_camera_random_deltas"):
self._camera_random_deltas = {}
self._camera_random_deltas[env_id] = {
"pos_delta": pos_delta.tolist() if isinstance(pos_delta, np.ndarray) else [0, 0, 0],
"roll_delta": float(roll_delta),
"pitch_delta": float(pitch_delta),
"yaw_delta": float(yaw_delta),
}
if env_id == 0 and not self._camera_extrinsics_randomized:
logger.info(
f" Env 0: pos_delta={pos_delta}, rot_delta=[{roll_delta:.3f}, {pitch_delta:.3f}, {yaw_delta:.3f}]" # noqa: E501
)
# Mark as initialized (for print suppression)
self._camera_extrinsics_randomized = True
def _decode_direct_latent(self, full_latent, atm_obs_dict):
"""Decode full latent directly using ATM decoder (skip encoder).
Used for student rollout where policy outputs full latent.
Args:
full_latent: (batch, latent_dim) full latent from student policy
atm_obs_dict: observation dict for ATM
Returns:
body_actions: (batch, seq_len, action_dim) decoded actions
""" # noqa: D205
# Get proprioception for decoder
if "policy_atm" in atm_obs_dict: # noqa: SIM108
proprioception = atm_obs_dict["policy_atm"]
else:
proprioception = atm_obs_dict["actor_obs"]
# Ensure sequence dimension
if proprioception.dim() == 2:
proprioception = proprioception.unsqueeze(1)
atm = self.action_transform_module.actor_module
return self._decode_direct_latent_batch(full_latent, proprioception, atm)
def _decode_direct_latent_batch(self, full_latent, proprioception, atm):
"""Decode a batch of full latents directly using ATM decoder.
The student policy outputs pre-quantization values (latent + residual).
We apply quantization here before decoding to match the teacher's flow:
- Teacher: latent + residual -> quantize -> decode
- Student: direct_latent -> quantize -> decode
Args:
full_latent: (batch, latent_dim) full latent (pre-quantization)
proprioception: (batch, seq, proprio_dim) proprioception input
atm: ATM actor module
Returns:
body_actions: (batch, seq_len, action_dim) decoded actions
"""
batch_size = full_latent.shape[0]
# Reshape latent for quantization: (batch, latent_dim) -> (batch, num_tokens, token_dim)
latent_reshaped = full_latent.view(batch_size, atm.max_num_tokens, atm.token_dim)
# Apply quantization (same as teacher's flow)
# This ensures student inference matches teacher: latent -> quantize -> decode
if atm.quantizer is not None:
quantized_codes, _ = atm.quantizer(latent_reshaped)
tokens_for_decode = quantized_codes.contiguous()
else:
tokens_for_decode = latent_reshaped
# Reshape for decoder: (batch, num_tokens, token_dim) -> (batch, 1, num_tokens, token_dim)
tokens_reshaped = tokens_for_decode.unsqueeze(1)
tokens_flattened = tokens_for_decode.view(batch_size, -1).unsqueeze(1)
# Prepare decode input
decode_input_dict = {
"token": tokens_reshaped,
"token_flattened": tokens_flattened, # (batch, 1, latent_dim)
"proprioception": proprioception,
}
# Decode directly (skip encoding entirely)
decoded_output = atm.decode("g1_dyn", decode_input_dict)
# Get body actions from decoder output
# Note: "meta_action" is for special hierarchical policies, "action" is standard
body_actions = decoded_output.get("meta_action", decoded_output.get("action"))
if body_actions is None:
raise KeyError(
f"Decoder output missing 'action' or 'meta_action'. Keys: {decoded_output.keys()}"
)
return body_actions
def step(self, actions):
if self.action_transform_module is not None:
# Use provided obs_dict or fall back to stored obs from last reset/step
if "obs_dict" in actions:
obs_dict = actions["obs_dict"].copy()
else:
# Fallback for callbacks that don't provide obs_dict (e.g., im_eval)
obs_dict = getattr(self, "_last_obs_dict", None)
if obs_dict is None:
raise ValueError(
"action_transform_module requires obs_dict but none was provided or stored"
)
obs_dict = obs_dict.copy() if isinstance(obs_dict, dict) else obs_dict
meta_actions = actions["actions"]
# Determine action mode: "direct_latent", "residual", or "mixed"
# Priority: 1) explicit action_mode in actions dict, 2) config flag
# During training: trainer sets action_mode explicitly
# During eval: fallback to config flags
action_mode = actions.get("action_mode", None)
if action_mode is None:
# Fallback for eval scripts that don't set action_mode
if self._use_student_direct_latent:
action_mode = "direct_latent"
elif self._use_latent_residual:
action_mode = "residual"
else:
action_mode = "residual" # Default to residual if nothing specified
# Shift meta action buffers for meta_action_rate_l2 reward (token smoothness)
self.env._prev_meta_action = self.env._last_meta_action.clone() # noqa: SLF001
# Store meta action for observation (last policy output)
self.env._last_meta_action = meta_actions.clone() # noqa: SLF001
atm_obs_dict = self._prepare_obs_for_action_transform_module(obs_dict)
# Split actions: first tokenizer_action_dim for tokenizer, rest for hands
tokenizer_action_dim = self.config.get("tokenizer_action_dim")
tokenizer_meta_actions = meta_actions[:, :tokenizer_action_dim]
hand_actions_raw = meta_actions[:, tokenizer_action_dim:]
# Override hand actions with motion data if configured
if self.config.get("use_motion_hand_actions", False):
motion_cmd = self.env.command_manager.get_term("motion")
left_action = motion_cmd.get_hand_action("left_hand")
right_action = motion_cmd.get_hand_action("right_hand")
if left_action is None or right_action is None:
raise ValueError(
"use_motion_hand_actions=True but hand_action_left/right not found in motion data. "
"Ensure processed_robot_motions.pkl contains 'hand_action_left' and 'hand_action_right' arrays." # noqa: E501
)
# Use motion data directly: -1.0 = open, +1.0 = closed
# Threshold at 0 in _convert_primitive_to_finger_actions
hand_actions_raw = torch.stack([left_action, right_action], dim=-1)
# Convert primitive actions to finger joint targets if enabled
if self._use_finger_primitive and self._finger_primitive_map:
# Store raw primitive actions on env for reward computation (before clamping)
self.env._finger_primitive_actions_raw = hand_actions_raw # noqa: SLF001
hand_actions = self._convert_primitive_to_finger_actions(hand_actions_raw)
else:
self.env._finger_primitive_actions_raw = None # noqa: SLF001
hand_actions = hand_actions_raw
if action_mode == "direct_latent":
# Student direct latent mode: policy outputs FULL latent, not residual
# Skip ATM encoder entirely - go directly to decoder
body_actions = self._decode_direct_latent(tokenizer_meta_actions, atm_obs_dict)
elif action_mode == "residual":
# Teacher/residual mode: policy outputs residual that's added to ATM encoded tokens
# Apply scaling to residual before passing to ATM
scaled_residual = tokenizer_meta_actions * self._latent_residual_scale
# Add residual in latent/token space (after encoding, before decoding)
body_actions = self.action_transform_module(
atm_obs_dict,
latent_residual=scaled_residual,
latent_residual_mode=self._latent_residual_mode,
)
elif action_mode == "mixed":
# Mixed rollout: some envs use teacher (residual), some use student (direct_latent)
# is_teacher_env is a boolean mask: True = teacher/residual, False = student/direct_latent
is_teacher_env = actions.get("is_teacher_env")
if is_teacher_env is None:
raise ValueError(
"action_mode='mixed' requires 'is_teacher_env' mask in actions dict"
)
num_envs = tokenizer_meta_actions.shape[0]
atm = self.action_transform_module.actor_module
# Get proprioception for decoder (needed for student/direct_latent mode)
if "policy_atm" in atm_obs_dict:
proprioception = atm_obs_dict["policy_atm"]
else:
proprioception = atm_obs_dict["actor_obs"]
if proprioception.dim() == 2:
proprioception = proprioception.unsqueeze(1)
teacher_mask = is_teacher_env
student_mask = ~is_teacher_env
num_teacher = teacher_mask.sum().item()
num_student = student_mask.sum().item()
# Initialize placeholders
teacher_indices = None
student_indices = None
teacher_body_actions = None
student_body_actions = None
# Process teacher envs (residual mode) if any
if num_teacher > 0:
teacher_indices = teacher_mask.nonzero(as_tuple=True)[0]
teacher_latent = tokenizer_meta_actions[teacher_indices]
# Prepare teacher obs dict (subset of envs)
# Only include keys that ATM actually needs: tokenizer and actor_obs
teacher_atm_obs = {}
atm_keys = ["tokenizer", "actor_obs"]
for obs_key in atm_keys:
if obs_key in atm_obs_dict:
teacher_atm_obs[obs_key] = atm_obs_dict[obs_key][teacher_indices]
scaled_residual = teacher_latent * self._latent_residual_scale
teacher_body_actions = self.action_transform_module(
teacher_atm_obs,
latent_residual=scaled_residual,
latent_residual_mode=self._latent_residual_mode,
)
# Process student envs (direct_latent mode) if any
if num_student > 0:
student_indices = student_mask.nonzero(as_tuple=True)[0]
student_latent = tokenizer_meta_actions[student_indices]
student_proprio = proprioception[student_indices]
student_body_actions = self._decode_direct_latent_batch(
student_latent, student_proprio, atm
)
# Merge results - determine output shape from whichever mode ran
if teacher_body_actions is not None:
out_seq_len = teacher_body_actions.shape[1]
out_action_dim = teacher_body_actions.shape[2]
dtype = teacher_body_actions.dtype
elif student_body_actions is not None:
out_seq_len = student_body_actions.shape[1]
out_action_dim = student_body_actions.shape[2]
dtype = student_body_actions.dtype
else:
raise RuntimeError("Mixed mode: no envs to process (both masks empty)")
body_actions = torch.zeros(
num_envs, out_seq_len, out_action_dim, device=self.device, dtype=dtype
)
if teacher_body_actions is not None and teacher_indices is not None:
body_actions[teacher_indices] = teacher_body_actions
if student_body_actions is not None and student_indices is not None:
body_actions[student_indices] = student_body_actions
else:
raise ValueError(
f"Unknown action_mode: {action_mode}. "
f"Valid modes are 'direct_latent', 'residual', or 'mixed'."
)
# Store full latent (decoder input) for full_latent_rate_l2 reward
# Only needed for residual mode (teacher RL training); student modes
# (direct_latent, mixed) use L2 distillation loss, not RL rewards.
if action_mode == "residual":
self.env._prev_full_latent = self.env._full_latent.clone() # noqa: SLF001
atm_module = self.action_transform_module.actor_module
if (
hasattr(atm_module, "_last_full_latent_flat")
and atm_module._last_full_latent_flat is not None # noqa: SLF001
):
fl = atm_module._last_full_latent_flat # noqa: SLF001
if fl.dim() == 3:
fl = fl[:, -1, :] # (batch, latent_dim)
self.env._full_latent = fl.to(self.env.device) # noqa: SLF001
body_actions = body_actions[:, -1] # Take last timestep
if (
self._body_joint_indices is not None
and self._hand_joint_indices is not None
and len(self._body_joint_indices) > 0
and len(self._hand_joint_indices) > 0
):
num_envs = body_actions.shape[0]
env_actions = torch.zeros(
num_envs, self._current_num_joints, device=self.device, dtype=body_actions.dtype
)
env_actions[:, self._body_joint_indices] = body_actions
env_actions[:, self._hand_joint_indices] = hand_actions
else:
env_actions = torch.cat([body_actions, hand_actions], dim=-1)
else:
env_actions = actions["actions"]
action_clip_value = self.config.get("action_clip_value", None)
if action_clip_value is not None and action_clip_value > 0:
env_actions = torch.clip(env_actions, -action_clip_value, action_clip_value)
# Lightweight action plot update (env 0, first N joints)
if self.turn_on_visualization:
try:
if self._action_fig is None:
self._init_action_plot(env_actions.shape[-1])
if (self._step_counter % self._viz_every_n_steps) == 0:
self._update_action_plot(env_actions)
self._step_counter += 1
except Exception: # noqa: S110, BLE001
pass
obs_dict, rew, terminated, truncated, extras = self.env.step(env_actions)
# compute dones for compatibility with RSL-RL
dones = (terminated | truncated).to(dtype=torch.long)
# Zero out action/latent rate buffers for envs that just reset
# This prevents a false large rate penalty on the first step of a new episode
# Only applies when action_transform_module is used (buffers created in reset())
reset_mask = dones.bool()
if reset_mask.any() and hasattr(self.env, "_prev_meta_action"):
self.env._prev_meta_action[reset_mask] = 0.0 # noqa: SLF001
self.env._last_meta_action[reset_mask] = 0.0 # noqa: SLF001
self.env._prev_full_latent[reset_mask] = 0.0 # noqa: SLF001
self.env._full_latent[reset_mask] = 0.0 # noqa: SLF001
# Compute success_lift metric: check if object has no contact with table (lifted)
# Skip frames before first contact (from contact label data)
if (
hasattr(self.env, "scene")
and "object_to_table_contact_sensor" in self.env.scene.sensors
):
from isaaclab.sensors import ContactSensor
sensor: ContactSensor = self.env.scene["object_to_table_contact_sensor"]
contact_force = sensor.data.force_matrix_w # [num_envs, 1, 1, 3]
force_magnitude = torch.norm(contact_force, dim=-1).sum(dim=(-1, -2)) # [num_envs]
contact_force_threshold = self.config.get("lift_contact_force_threshold", 0.1)
if hasattr(self.env, "success_lift"):
self.env.success_lift = self.env.success_lift & (~dones.bool())
# Get first contact frame from motion command (contact label data)
is_before_contact = torch.ones(
self.env.num_envs, dtype=torch.bool, device=self.env.device
)
if self.motion_command is not None:
per_env_first_contact = getattr(self.motion_command, "_per_env_first_contact", None)
if per_env_first_contact is not None:
current_time = (
self.motion_command.motion_start_time_steps + self.motion_command.time_steps
)
is_before_contact = current_time < per_env_first_contact
# Object is currently lifted if there's no contact force from table
is_currently_lifted = force_magnitude <= contact_force_threshold
# Only update success_lift after first contact (cumulative OR)
# Before first contact, success_lift stays False regardless of contact
self.env.success_lift = torch.where(
is_before_contact,
self.env.success_lift, # Keep current value (False after reset)
is_currently_lifted | self.env.success_lift,
)
else:
# If no contact sensor, set to False for all envs
self.env.success_lift = torch.zeros(
self.env.num_envs, dtype=torch.bool, device=self.env.device
)
extras["time_outs"] = truncated
extras["episode"] = {}
extras["to_log"] = {}
for k, v in extras["log"].items():
if isinstance(v, torch.Tensor):
extras["to_log"][k] = v
else:
extras["to_log"][k] = torch.tensor(v, dtype=torch.float)
if self._motion_lib is not None and self._motion_lib.use_adaptive_sampling:
extras["to_log"][
"adp_samp/num_episodes_min"
] = self._motion_lib.adp_samp_num_episodes.min()
extras["to_log"][
"adp_samp/num_episodes_max"
] = self._motion_lib.adp_samp_num_episodes.max()
extras["to_log"][
"adp_samp/num_episodes_mean"
] = self._motion_lib.adp_samp_num_episodes.mean()
extras["to_log"][
"adp_samp/num_failures_min"
] = self._motion_lib.adp_samp_num_failures.min()
extras["to_log"][
"adp_samp/num_failures_max"
] = self._motion_lib.adp_samp_num_failures.max()
extras["to_log"][
"adp_samp/num_failures_mean"
] = self._motion_lib.adp_samp_num_failures.mean()
extras["to_log"][
"adp_samp/failure_rate_min"
] = self._motion_lib.adp_samp_failure_rate_raw.min()
extras["to_log"][
"adp_samp/failure_rate_max"
] = self._motion_lib.adp_samp_failure_rate_raw.max()
extras["to_log"][
"adp_samp/failure_rate_mean"
] = self._motion_lib.adp_samp_failure_rate_raw.mean()
if hasattr(self._motion_lib, "adp_sampling_active_prob"):
prob = self._motion_lib.adp_sampling_active_prob
uniform_prob = 1.0 / len(prob) if len(prob) > 0 else 1.0
extras["to_log"]["adp_samp/prob_max"] = prob.max()
extras["to_log"]["adp_samp/prob_min"] = prob.min()
extras["to_log"]["adp_samp/prob_mean"] = prob.mean()
extras["to_log"]["adp_samp/prob_max_over_uniform"] = prob.max() / uniform_prob
extras["to_log"]["adp_samp/effective_num_bins"] = 1.0 / (prob**2).sum()
# How many bins have prob > 10x uniform (significantly concentrated)
# Note: max allowed is 50x uniform, so 10x is 20% of the cap
extras["to_log"]["adp_samp/num_concentrated_bins"] = (
(prob > 10 * uniform_prob).sum().float()
)
eps_mean = self._motion_lib.adp_samp_num_episodes.mean()
if eps_mean > 0:
extras["to_log"]["adp_samp/episodes_max_over_mean"] = (
self._motion_lib.adp_samp_num_episodes.max() / eps_mean
)
new_obs = self.process_raw_obs(obs_dict, flatten_dict_obs=True)
# Store obs for action_transform_module when obs_dict is not provided in next step()
self._last_obs_dict = new_obs
self.extras = extras
# Store env_actions for callbacks (e.g., MultiLatentSaveCallback)
extras["env_actions"] = env_actions.detach().cpu()
return new_obs, rew, dones, extras
def get_env_data(self, key):
if key == "ref_body_pos_extend":
return self.motion_command.robot_body_pos_w
elif key == "rigid_body_pos_extend":
return self.motion_command.body_pos_w
else:
return self.env.get_env_data(key)
def render_results(self):
pass
def end_render_results(self):
if "render_envs" in self.env.recorder_manager._terms: # noqa: SLF001
self.env.recorder_manager._terms["render_envs"].close_writers() # noqa: SLF001
if "trajectory" in self.env.recorder_manager._terms: # noqa: SLF001
self.env.recorder_manager._terms["trajectory"].close_writers() # noqa: SLF001
if self._action_fig is not None:
try:
import matplotlib.pyplot as plt
plt.close(self._action_fig)
except Exception: # noqa: S110, BLE001
pass
self._action_fig = None
self._action_lines = None
self._action_hist = None
self._hist_idx = 0
self._blit_background = None
def set_is_evaluating(self, is_evaluating: bool = True, global_rank=0, **_kwargs):
self.is_evaluating = is_evaluating
if self.motion_command is not None:
self.motion_command.set_is_evaluating(is_evaluating)
if self.force_command is not None:
self.force_command.set_is_evaluating(is_evaluating)
if is_evaluating:
self.begin_seq_motion_samples(global_rank)
def begin_seq_motion_samples(self, global_rank=0):
logger.info("Loading motions for evaluation")
self.start_idx = global_rank * self.num_envs
self._motion_lib.load_motions_for_evaluation(start_idx=self.start_idx)
self.reset_all(global_rank=global_rank)
def forward_motion_samples(self, global_rank=0, world_size=1):
old_start_idx = self.start_idx
self.start_idx += world_size * self.num_envs
logger.info(
f"Forward motions for evaluation from {old_start_idx} to {self.start_idx} - rank: {global_rank} - world size: {world_size}" # noqa: E501
)
self._motion_lib.load_motions_for_evaluation(start_idx=self.start_idx)
self.reset_all(global_rank=global_rank)
def focusing_viewer(self):
if not self.viewer_focused:
# Focus on robot asset (tracking mode)
self.env.viewport_camera_controller.update_view_to_world()
self.env.viewport_camera_controller.update_view_to_asset_root("robot")
self.viewer_focused = True
logger.info("Focused on robot")
else:
# Switch to free camera mode centered on robot
self.env.viewport_camera_controller.viewer_origin = torch.zeros_like(
self.env.scene["robot"].data.root_pos_w[0]
)
self.env.viewport_camera_controller.cfg.origin_type = "world"
cam_eye = self.env.scene["robot"].data.root_pos_w[0].cpu().numpy() + np.array(
[2.0, 2.0, 1.5]
)
cam_target = self.env.scene["robot"].data.root_pos_w[0].cpu().numpy()
self.env.viewport_camera_controller._env.sim.set_camera_view( # noqa: SLF001
eye=cam_eye, target=cam_target
)
self.viewer_focused = False
logger.info("Switched to free camera mode") # noqa: RUF100, T201
def set_is_training(self, **_kwargs):
self.is_evaluating = False
self.resample_motion()
def resample_motion(self):
res = self._motion_lib.load_motions_for_training(
max_num_seqs=min(self.num_envs, self.motion_command.max_num_load_motions)
)
if res:
self.reset_all()
else:
logger.info("No new motions loaded, skipping reset") # noqa: RUF100, T201
def sync_and_compute_adaptive_sampling(self, accelerator, sync_across_gpus=False):
if self._motion_lib is not None:
self._motion_lib.sync_and_compute_adaptive_sampling(
accelerator, sync_across_gpus=sync_across_gpus
)
def load_env_state_dict(self, state_dict):
if "motion_lib" in state_dict:
self._motion_lib.load_state_dict(state_dict["motion_lib"])
if self._motion_lib.use_adaptive_sampling:
self.resample_motion()
def get_env_state_dict(self):
state_dict = {
"motion_lib": self._motion_lib.get_state_dict(),
}
return state_dict
def reinit_dr(self, **_kwargs):
pass
def setup_keyboard(self):
try:
from isaaclab.devices.keyboard.se2_keyboard import Se2Keyboard, Se2KeyboardCfg
cfg = Se2KeyboardCfg()
self.keyboard_interface = Se2Keyboard(cfg)
self.keyboard_interface.add_callback("R", self.reset_all)
self.keyboard_interface.add_callback("T", self.forward_motion_samples)
self.keyboard_interface.add_callback("F", self.focusing_viewer)
self.keyboard_interface.add_callback("V", self.toggle_debug_vis)
except Exception as e: # noqa: BLE001
logger.info(f"Error setting up keyboard: {e}") # noqa: RUF100, T201
def toggle_debug_vis(self):
if self.motion_command is not None and hasattr(self.motion_command, "_set_debug_vis_impl"):
self._debug_vis_enabled = not getattr(self, "_debug_vis_enabled", True)
self.motion_command._set_debug_vis_impl(self._debug_vis_enabled) # noqa: SLF001
logger.info(f"Debug visualization: {'ON' if self._debug_vis_enabled else 'OFF'}") # noqa: RUF100, T201
# --- Action plotting helpers ---
def _init_action_plot(self, action_dim: int):
plot_dim = min(int(self._plot_action_dim), int(action_dim))
if plot_dim <= 0:
return
try:
import matplotlib.pyplot as plt
except Exception as e: # noqa: BLE001, F841
return
plt.ion()
import math
cols = math.ceil(math.sqrt(plot_dim))
rows = math.ceil(plot_dim / cols)
fig, axes = plt.subplots(
rows, cols, sharex=True, sharey=True, figsize=(cols * 3.0, rows * 2.2)
)
axes = np.array(axes).reshape(-1)
self._plot_window = max(10, int(self._plot_window))
x_vals = np.arange(self._plot_window)
self._action_hist = np.zeros((self._plot_window, plot_dim), dtype=np.float32)
lines = []
y_min, y_max = -float(self._action_ylim), float(self._action_ylim)
for i in range(rows * cols):
ax_i = axes[i]
if i < plot_dim:
(ln,) = ax_i.plot(x_vals, self._action_hist[:, i], linewidth=1.0)
ln.set_animated(True)
lines.append(ln)
ax_i.set_ylim((y_min, y_max))
ax_i.set_xlim((0, self._plot_window - 1))
ax_i.set_title(f"J{i}", fontsize=8)
if i // cols == rows - 1:
ax_i.set_xlabel("step", fontsize=8)
if i % cols == 0:
ax_i.set_ylabel("act", fontsize=8)
else:
ax_i.axis("off")
fig.suptitle(f"Action time series (env 0) - first {plot_dim} joints", fontsize=10)
fig.tight_layout(rect=(0, 0.02, 1, 0.96))
self._action_fig = fig
self._action_lines = lines
self._hist_idx = -1
try:
fig.canvas.draw()
self._blit_background = fig.canvas.copy_from_bbox(fig.bbox)
except Exception: # noqa: BLE001
self._blit_background = None
self._blit_supported = False
fig.canvas.flush_events()
def _update_action_plot(self, env_actions: torch.Tensor):
if self._action_lines is None or self._action_hist is None:
return
plot_dim = len(self._action_lines)
with torch.no_grad():
vals = env_actions[0, :plot_dim].detach().to("cpu").numpy().astype(np.float32)
vals = np.clip(vals, -self._action_ylim, self._action_ylim)
# advance circular buffer
self._hist_idx = (self._hist_idx + 1) % self._plot_window
self._action_hist[self._hist_idx, :plot_dim] = vals
# display in chronological order using a view with roll
disp = np.roll(self._action_hist, shift=-(self._hist_idx + 1), axis=0)
for i, ln in enumerate(self._action_lines):
ln.set_ydata(disp[:, i])
if self._action_fig is not None:
canvas = self._action_fig.canvas
# Periodically refresh background to handle resizes or overdraw
if self._blit_supported and (
self._blit_background is None
or (self._step_counter % max(1, self._blit_refresh_interval) == 0)
):
try:
self._action_fig.canvas.draw()
self._blit_background = canvas.copy_from_bbox(self._action_fig.bbox)
except Exception: # noqa: BLE001
self._blit_background = None
self._blit_supported = False
if self._blit_supported and self._blit_background is not None:
try:
canvas.restore_region(self._blit_background)
for ln in self._action_lines:
ln.axes.draw_artist(ln)
canvas.blit(self._action_fig.bbox)
canvas.flush_events()
return
except Exception: # noqa: BLE001
self._blit_supported = False
# Fallback full redraw
canvas.draw_idle()
canvas.flush_events()
@property
def motion_ids(self):
return self.motion_command.motion_ids
def setup_replay_grid(self, spacing=2.0, rows=None, cols=None):
"""Setup a custom grid layout for environment origins during replay.
Args:
spacing: Distance between environments in meters (default: 2.0)
rows: Number of rows in the grid (default: auto-calculated)
cols: Number of columns in the grid (default: auto-calculated)
Returns:
Tensor of shape (num_envs, 3) with custom origins
"""
import math
num_envs = self.num_envs
# Auto-calculate grid dimensions if not provided
if rows is None and cols is None:
# Try to make a square-ish grid
cols = int(math.ceil(math.sqrt(num_envs))) # noqa: RUF046
rows = int(math.ceil(num_envs / cols)) # noqa: RUF046
elif rows is None:
rows = int(math.ceil(num_envs / cols)) # noqa: RUF046
elif cols is None:
cols = int(math.ceil(num_envs / rows)) # noqa: RUF046
logger.info(f"Setting up replay grid: {rows} rows x {cols} cols with {spacing}m spacing")
# Create grid origins
custom_origins = torch.zeros((num_envs, 3), device=self.device, dtype=torch.float32)
for i in range(num_envs):
row = i // cols
col = i % cols
# Center the grid around origin
x_offset = (col - (cols - 1) / 2.0) * spacing
y_offset = (row - (rows - 1) / 2.0) * spacing
custom_origins[i, 0] = x_offset
custom_origins[i, 1] = y_offset
custom_origins[i, 2] = 0.0 # Keep z at ground level
# Store custom origins
self._replay_custom_origins = custom_origins
logger.info(
f"Grid bounds: X=[{custom_origins[:, 0].min():.1f}, {custom_origins[:, 0].max():.1f}], "
f"Y=[{custom_origins[:, 1].min():.1f}, {custom_origins[:, 1].max():.1f}]"
)
return custom_origins
def run_replay(
self,
motion_id=None,
start_time_step=0,
speed=1.0,
loop=True,
enable_vis=False,
target_fps=50,
grid_spacing=2.0,
grid_rows=None,
grid_cols=None,
save_video_path=None,
):
"""Run a complete motion replay with automatic rendering loop for all environments.
This is the high-level convenience function that handles everything.
Args:
motion_id: The motion ID(s) to replay. Can be:
- None: uses current motion_ids for all envs
- int: single motion ID for all envs
- list/tensor: motion ID per environment
start_time_step: Starting time step in the motion (default: 0)
speed: Playback speed multiplier (default: 1.0)
loop: Whether to loop the motion (default: True)
enable_vis: Whether to enable debug visualization (default: False)
target_fps: Target frames per second for rendering (default: 50)
grid_spacing: Distance between environments in meters (default: 2.0)
grid_rows: Number of rows in grid layout (default: auto)
grid_cols: Number of columns in grid layout (default: auto)
save_video_path: Path to save video file (default: None, no video saved)
Requires render_results=True in config to enable eval_camera.
Returns:
List of dictionaries with motion metadata for each environment
""" # noqa: D205
import os
import time
# Setup custom grid origins
self.setup_replay_grid(spacing=grid_spacing, rows=grid_rows, cols=grid_cols)
# Initialize replay
info = self.setup_replay_motion(
motion_id=motion_id,
start_time_step=start_time_step,
speed=speed,
loop=loop,
enable_vis=enable_vis,
)
if info is None:
return None
# Initialize video writer if save_video_path is specified
video_writer = None
if save_video_path is not None:
if "eval_camera" not in self.env.scene.sensors:
logger.info("WARNING: save_video_path specified but eval_camera not available.")
logger.info(" Set ++manager_env.config.render_results=True to enable it.")
else:
import imageio
os.makedirs(os.path.dirname(os.path.abspath(save_video_path)), exist_ok=True)
video_writer = imageio.get_writer(
save_video_path,
fps=target_fps,
codec="libx264",
quality=5,
pixelformat="yuv420p",
)
logger.info(f"[Video] Recording to: {save_video_path}")
# Run the rendering loop
frame_time = 1.0 / target_fps
last_time = time.time()
frame_count = 0
try:
while self.step_replay():
# Position camera BEFORE rendering (if recording)
if video_writer is not None and "eval_camera" in self.env.scene.sensors:
root_pos = self.motion_command.robot.data.body_pos_w[:, 0]
camera_offset = self.config.get("eval_camera_offset", [-2, -2, 1])
eye = root_pos + torch.tensor(camera_offset, device=self.device)
self.env.scene["eval_camera"].set_world_poses_from_view(eye, root_pos)
# Render the scene (triggers camera render in headless mode)
if hasattr(self.env, "sim"):
try:
self.env.sim.render()
except Exception as e: # noqa: BLE001
logger.info(f"Render error: {e}")
# Capture video frame if recording
if video_writer is not None and "eval_camera" in self.env.scene.sensors:
# Update camera to refresh its data after render
self.env.scene["eval_camera"].update(dt=0.0)
# Grab frame from camera
rgb_frame = self.env.scene["eval_camera"].data.output["rgb"]
frame = rgb_frame[0].cpu().numpy().astype(np.uint8)
video_writer.append_data(frame)
frame_count += 1
# Frame rate limiting (only when not headless or not saving video)
if not self.config.get("headless", False) or video_writer is None:
current_time = time.time()
elapsed = current_time - last_time
sleep_time = max(0, (frame_time / speed) - elapsed)
if sleep_time > 0:
time.sleep(sleep_time)
last_time = time.time()
except KeyboardInterrupt:
logger.info("\nReplay interrupted by user")
# Close video writer
if video_writer is not None:
video_writer.close()
logger.info(f"[Video] Saved {frame_count} frames to: {save_video_path}")
logger.info("\nReplay complete!")
return info
def setup_replay_motion(
self, motion_id=None, start_time_step=0, speed=1.0, loop=True, enable_vis=False
):
"""Initialize motion replay for all environments (low-level function for manual control).
Use run_replay() instead if you want automatic rendering loop.
After calling this, you need to manually call step_replay() in a loop
and handle rendering yourself. For automatic rendering, use run_replay().
Args:
motion_id: The motion ID(s) to replay. Can be:
- None: uses current motion_ids for all envs
- int: single motion ID for all envs
- list/tensor: motion ID per environment
start_time_step: Starting time step in the motion (default: 0)
speed: Playback speed multiplier (default: 1.0)
loop: Whether to loop the motion (default: True)
enable_vis: Whether to enable debug visualization (default: False)
Returns:
List of dictionaries with motion metadata for each environment
""" # noqa: D205
if self._motion_lib is None:
logger.info("No motion library available for replay")
return None
# Replay all environments
num_replay_envs = self.num_envs
replay_env_ids = torch.arange(num_replay_envs, device=self.device, dtype=torch.long)
# Get motion IDs
if motion_id is None:
motion_ids = self.motion_command.motion_ids
elif isinstance(motion_id, int):
motion_ids = torch.full(
(num_replay_envs,), motion_id, device=self.device, dtype=torch.long
)
elif isinstance(motion_id, list | tuple):
if len(motion_id) != num_replay_envs:
logger.info(
f"Error: motion_id list length ({len(motion_id)}) doesn't match number of environments ({num_replay_envs})" # noqa: E501
)
return None
motion_ids = torch.tensor(motion_id, device=self.device, dtype=torch.long)
elif isinstance(motion_id, torch.Tensor):
if motion_id.shape[0] != num_replay_envs:
logger.info(
f"Error: motion_id tensor size ({motion_id.shape[0]}) doesn't match number of environments ({num_replay_envs})" # noqa: E501
)
return None
motion_ids = motion_id.to(self.device)
else:
logger.info(f"Error: Invalid motion_id type: {type(motion_id)}")
return None
# Get motion info for display
num_steps_per_env = self._motion_lib.get_motion_num_steps(motion_ids)
max_num_steps = num_steps_per_env.max().item()
unique_motions = torch.unique(motion_ids)
logger.info(f"\n{'='*60}")
logger.info(f"Batch Replaying {num_replay_envs} Environments")
logger.info(f"Unique motion IDs: {unique_motions.tolist()}")
logger.info(f"Max frames: {max_num_steps}")
logger.info(f"Max duration: {max_num_steps / 50.0:.2f}s (@ 50 FPS)")
logger.info(f"Speed: {speed}x")
logger.info(f"{'='*60}\n")
# Enable visualization if requested
if enable_vis and hasattr(self.motion_command, "_set_debug_vis_impl"):
self.motion_command._set_debug_vis_impl(True) # noqa: SLF001
# Initialize contact point visualization if available and enabled
self._replay_contact_visualizer = None
self._replay_vis_enabled = enable_vis
if enable_vis and VISUALIZATION_AVAILABLE:
self._setup_contact_center_visualizer()
# Replay state
self._replay_active = True
self._replay_paused = False
self._replay_reverse = False
self._replay_time_steps = torch.full(
(num_replay_envs,), start_time_step, device=self.device, dtype=torch.long
)
self._replay_motion_ids = motion_ids
self._replay_env_ids = replay_env_ids
self._replay_speed = speed
self._replay_loop = loop
self._replay_num_steps_per_env = num_steps_per_env
self._replay_max_num_steps = max_num_steps
# Pre-load table metadata from pkl files for ALL motions (per-env)
# This supports multi-motion replay where each env can have different table positions
self._replay_table_pos = None # Shape: (num_envs, 3)
self._replay_table_quat = None # Shape: (num_envs, 4)
if hasattr(self.env, "scene") and "table" in self.env.scene.rigid_objects:
try:
import os
import joblib
# Derive meta directory from motion_file path in config
motion_lib_cfg = getattr(self.motion_command.cfg, "motion_lib_cfg", None)
motion_file = motion_lib_cfg.get("motion_file", "") if motion_lib_cfg else ""
if motion_file and "/robot" in motion_file:
if os.path.isdir(motion_file):
meta_dir = motion_file.replace("/robot", "/meta")
else:
meta_dir = os.path.dirname(motion_file).replace("/robot", "/meta")
else:
meta_dir = "data/motion_lib_grab/meta"
# Pre-load motion file data if it's a single file (for table fallback)
motion_file_data = None
if motion_file and os.path.isfile(motion_file) and not os.path.isdir(meta_dir):
motion_file_data = joblib.load(motion_file)
# Load table metadata for each environment's assigned motion
table_pos_list = []
table_quat_list = []
table_scale_list = [] # Also load scales for offset calculation
loaded_count = 0
loaded_from_motion_file = 0
for env_idx in range(num_replay_envs):
motion_idx = motion_ids[env_idx].item()
motion_key = self._motion_lib.curr_motion_keys[motion_idx]
meta_file = os.path.join(meta_dir, f"{motion_key}.pkl")
if os.path.exists(meta_file):
meta = joblib.load(meta_file)
pos = torch.tensor(
meta.get("table_pos", [0.0, 0.0, 0.8]), device=self.device
).float()
quat = torch.tensor(
meta.get("table_quat", [1.0, 0.0, 0.0, 0.0]), device=self.device
).float()
scale = torch.tensor(
meta.get("table_scale", [1.0, 1.0, 1.0]), device=self.device
).float()
table_pos_list.append(pos)
table_quat_list.append(quat)
table_scale_list.append(scale)
loaded_count += 1
elif motion_file_data is not None and motion_key in motion_file_data:
# Fallback: try to get table data from motion file directly
motion_data = motion_file_data[motion_key]
if "table_pos" in motion_data and "table_quat" in motion_data:
pos = torch.tensor(motion_data["table_pos"], device=self.device).float()
quat = torch.tensor(
motion_data["table_quat"], device=self.device
).float()
scale = torch.tensor(
motion_data.get("table_scale", [1.0, 1.0, 1.0]), device=self.device
).float()
table_pos_list.append(pos)
table_quat_list.append(quat)
table_scale_list.append(scale)
loaded_from_motion_file += 1
else:
# No table data in motion file, use default
table_pos_list.append(
torch.tensor([0.0, 0.0, 0.8], device=self.device).float()
)
table_quat_list.append(
torch.tensor([1.0, 0.0, 0.0, 0.0], device=self.device).float()
)
table_scale_list.append(
torch.tensor([1.0, 1.0, 1.0], device=self.device).float()
)
else:
# Fallback: use default table position
table_pos_list.append(
torch.tensor([0.0, 0.0, 0.8], device=self.device).float()
)
table_quat_list.append(
torch.tensor([1.0, 0.0, 0.0, 0.0], device=self.device).float()
)
table_scale_list.append(
torch.tensor([1.0, 1.0, 1.0], device=self.device).float()
)
self._replay_table_pos = torch.stack(table_pos_list, dim=0) # (num_envs, 3)
self._replay_table_quat = torch.stack(table_quat_list, dim=0) # (num_envs, 4)
self._replay_table_scale = torch.stack(table_scale_list, dim=0) # (num_envs, 3)
# Apply fixed table_offset if configured
table_offset = self.config.get("table_offset", None)
if table_offset is not None:
offset_tensor = torch.tensor(
table_offset, device=self.device, dtype=self._replay_table_pos.dtype
)
self._replay_table_pos = self._replay_table_pos + offset_tensor
# Apply maximal X offset based on object starting position
# This computes the maximum valid offset range such that object stays on table
x_offset_maximal = self.config.get("replay_table_x_offset_maximal", False)
x_offset_margin = self.config.get(
"replay_table_x_offset_margin", 0.05
) # 5cm safety margin
if x_offset_maximal:
# Get base table width from config (required for maximal offset mode)
table_size_cfg = self.config.get("table_size")
if table_size_cfg is None:
raise ValueError(
"replay_table_x_offset_maximal requires table_size to be set in config"
)
base_table_width = table_size_cfg[0]
# Get object starting position (step 0) for each env
object_start_pos = self._motion_lib.get_object_root_pos(
motion_ids,
torch.zeros(num_replay_envs, device=self.device, dtype=torch.long),
)[
:, 0
] # Shape: (num_envs, 3) -> take first object if multiple
object_start_x = object_start_pos[:, 0] # (num_envs,)
# Get table position and scaled width per env
table_x = self._replay_table_pos[:, 0] # (num_envs,)
scale_x = self._replay_table_scale[:, 0] # (num_envs,)
actual_width = base_table_width * scale_x # (num_envs,)
half_width = actual_width / 2.0
# Compute valid offset range per env:
# Object must stay on table after offset dx
# dx_min = object_x - table_x - half_width + margin
# dx_max = object_x - table_x + half_width - margin
relative_obj_x = object_start_x - table_x
dx_min = relative_obj_x - half_width + x_offset_margin
dx_max = relative_obj_x + half_width - x_offset_margin
# Sample X offset uniformly within valid range per env
rand_vals = torch.rand(num_replay_envs, device=self.device)
x_offsets = dx_min + rand_vals * (dx_max - dx_min)
self._replay_table_pos[:, 0] += x_offsets
logger.info("Replay table X offset (maximal mode):")
logger.info(
f" Base table width: {base_table_width:.3f}m, margin: {x_offset_margin:.3f}m"
)
logger.info(f" dx_min range: [{dx_min.min():.3f}, {dx_min.max():.3f}]")
logger.info(f" dx_max range: [{dx_max.min():.3f}, {dx_max.max():.3f}]")
logger.info(f" Applied offsets: [{x_offsets.min():.3f}, {x_offsets.max():.3f}]")
# Apply fixed XY offset range (legacy mode, used if maximal mode is disabled)
# Format: [x_min, x_max, y_min, y_max] - per-env randomized
xy_offset_range = self.config.get("replay_table_xy_offset_range", None)
if xy_offset_range is not None and not x_offset_maximal:
x_min, x_max, y_min, y_max = xy_offset_range
# Sample X offset per environment
x_offsets = torch.empty(num_replay_envs, device=self.device).uniform_(
x_min, x_max
)
self._replay_table_pos[:, 0] += x_offsets
# Sample Y offset per environment (typically y_min = y_max for fixed offset)
y_offsets = torch.empty(num_replay_envs, device=self.device).uniform_(
y_min, y_max
)
self._replay_table_pos[:, 1] += y_offsets
logger.info(
f"Replay table XY offset: X=[{x_min:.3f}, {x_max:.3f}], Y=[{y_min:.3f}, {y_max:.3f}]"
)
if loaded_count > 0:
logger.info(
f"Loaded table metadata for {loaded_count}/{num_replay_envs} environments from {meta_dir}"
)
elif loaded_from_motion_file > 0:
logger.info(
f"Loaded table metadata for {loaded_from_motion_file}/{num_replay_envs} environments from motion file" # noqa: E501
)
except Exception as e: # noqa: BLE001
logger.info(f"Warning: Could not load table metadata: {e}")
# Setup keyboard controls for replay
if not self.config.headless:
self._setup_replay_keyboard()
logger.info("Replay Controls:")
logger.info(" G: Pause/Resume")
logger.info(" B: Toggle Reverse Play")
logger.info(" LEFT/RIGHT: Step backward/forward (when paused)")
logger.info(" R: Restart from beginning")
logger.info(" ESC: Exit replay")
logger.info(" +/-: Increase/Decrease speed")
logger.info("")
# Return metadata
results = []
for i, (mid, nsteps) in enumerate(
zip(motion_ids.tolist(), num_steps_per_env.tolist(), strict=False)
):
# Get motion key from motion library (curr_motion_keys is the active list)
if hasattr(self._motion_lib, "curr_motion_keys") and mid < len(
self._motion_lib.curr_motion_keys
):
motion_key = self._motion_lib.curr_motion_keys[mid]
elif hasattr(self._motion_lib, "motion_keys") and mid < len(
self._motion_lib.motion_keys
):
motion_key = self._motion_lib.motion_keys[mid]
else:
motion_key = f"motion_{mid}"
results.append(
{
"env_id": i,
"motion_id": mid,
"motion_key": motion_key,
"num_steps": nsteps,
"duration": nsteps / 50.0,
}
)
return results
def _setup_replay_keyboard(self):
"""Setup keyboard controls for replay mode""" # noqa: D415
try:
if not hasattr(self, "keyboard_interface"):
from isaaclab.devices.keyboard.se2_keyboard import Se2Keyboard, Se2KeyboardCfg
cfg = Se2KeyboardCfg()
self.keyboard_interface = Se2Keyboard(cfg)
# Add replay-specific callbacks
self.keyboard_interface.add_callback("G", self._toggle_replay_pause)
self.keyboard_interface.add_callback("LEFT", self._replay_step_backward)
self.keyboard_interface.add_callback("RIGHT", self._replay_step_forward)
self.keyboard_interface.add_callback("R", self._restart_replay)
self.keyboard_interface.add_callback("ESCAPE", self._exit_replay)
self.keyboard_interface.add_callback("EQUAL", self._increase_replay_speed)
self.keyboard_interface.add_callback("MINUS", self._decrease_replay_speed)
self.keyboard_interface.add_callback("B", self._toggle_reverse_play)
except Exception as e: # noqa: BLE001
logger.info(f"Could not setup replay keyboard controls: {e}")
def _setup_contact_center_visualizer(self):
"""Setup visualization markers for per-hand contact centers during replay."""
if not VISUALIZATION_AVAILABLE:
return
has_left = hasattr(self._motion_lib, "_motion_object_contact_center_left")
has_right = hasattr(self._motion_lib, "_motion_object_contact_center_right")
if not has_left and not has_right:
logger.info("No contact center available in motion library for visualization")
return
try:
self._replay_contact_visualizers = {}
if has_left:
left_cfg = VisualizationMarkersCfg(
prim_path="/Visuals/Replay/contact_center_left",
markers={
"contact": sim_utils.SphereCfg(
radius=0.03,
visual_material=sim_utils.PreviewSurfaceCfg(
diffuse_color=(0.0, 0.0, 1.0) # Blue for left hand
),
),
},
)
self._replay_contact_visualizers["left_hand"] = VisualizationMarkers(left_cfg)
self._replay_contact_visualizers["left_hand"].set_visibility(True)
if has_right:
right_cfg = VisualizationMarkersCfg(
prim_path="/Visuals/Replay/contact_center_right",
markers={
"contact": sim_utils.SphereCfg(
radius=0.03,
visual_material=sim_utils.PreviewSurfaceCfg(
diffuse_color=(0.0, 1.0, 1.0) # Green for right hand
),
),
},
)
self._replay_contact_visualizers["right_hand"] = VisualizationMarkers(right_cfg)
self._replay_contact_visualizers["right_hand"].set_visibility(True)
hands = list(self._replay_contact_visualizers.keys())
logger.info(f"Contact center visualizer initialized for: {', '.join(hands)}")
# Keep _replay_contact_visualizer as a truthy check for the update loop
self._replay_contact_visualizer = True
except Exception as e: # noqa: BLE001
logger.info(f"Could not setup contact center visualizer: {e}")
self._replay_contact_visualizer = None
def _update_contact_center_visualization(self):
"""Update per-hand contact center visualization during replay."""
if not getattr(self, "_replay_contact_visualizers", None):
return
from gear_sonic.isaac_utils.rotations import quat_rotate
# Get object pose (shared by both hands)
object_root_pos = self._motion_lib.get_object_root_pos(
self._replay_motion_ids, self._replay_time_steps
)[
:, 0, :
] # (num_envs, 3)
object_root_quat = self._motion_lib.get_object_root_quat(
self._replay_motion_ids, self._replay_time_steps
)[
:, 0, :
] # (num_envs, 4)
# Environment origin offsets
if hasattr(self, "_replay_custom_origins"):
env_origins = self._replay_custom_origins[self._replay_env_ids]
else:
env_origins = self.env.scene.env_origins[self._replay_env_ids]
# Hidden position for markers when no contact
hidden_pos = torch.tensor([[0.0, 0.0, -1000.0]], device=self.device)
for hand, visualizer in self._replay_contact_visualizers.items():
contact_center = self._motion_lib.get_object_contact_center(
self._replay_motion_ids, self._replay_time_steps, hand=hand
)
if contact_center is None:
visualizer.visualize(translations=hidden_pos)
continue
# Move markers far away for envs with no contact (zero contact center)
valid_mask = torch.norm(contact_center, dim=-1) > 1e-6
rotated = quat_rotate(object_root_quat, contact_center, w_last=False)
world_center = rotated + object_root_pos + env_origins
world_center[~valid_mask] = hidden_pos
visualizer.visualize(translations=world_center)
def _toggle_replay_pause(self):
"""Toggle pause state for replay""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active:
self._replay_paused = not self._replay_paused
status = "PAUSED" if self._replay_paused else "PLAYING"
max_frame = self._replay_time_steps.max().item()
logger.info(f"Replay {status} (max frame {max_frame}/{self._replay_max_num_steps})")
def _replay_step_backward(self):
"""Step backward one frame when paused""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active and self._replay_paused:
self._replay_time_steps = torch.clamp(self._replay_time_steps - 1, min=0)
self._update_replay_frame()
max_frame = self._replay_time_steps.max().item()
logger.info(f"Frame {max_frame}/{self._replay_max_num_steps}")
self.env.sim.render()
def _replay_step_forward(self):
"""Step forward one frame when paused""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active and self._replay_paused:
self._replay_time_steps = torch.minimum(
self._replay_time_steps + 1, self._replay_num_steps_per_env - 1
)
self._update_replay_frame()
max_frame = self._replay_time_steps.max().item()
logger.info(f"Frame {max_frame}/{self._replay_max_num_steps}")
self.env.sim.render()
def _restart_replay(self):
"""Restart replay from beginning""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active:
self._replay_time_steps.fill_(0)
self._replay_paused = False
self._update_replay_frame()
logger.info("Replay restarted from beginning")
self.env.sim.render()
def _exit_replay(self):
"""Exit replay mode""" # noqa: D415
if hasattr(self, "_replay_active"):
self._replay_active = False
# Hide contact center visualizers
for vis in getattr(self, "_replay_contact_visualizers", {}).values():
vis.set_visibility(False)
logger.info("Exiting replay mode")
self.env.sim.render()
def _increase_replay_speed(self):
"""Increase replay speed""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active:
self._replay_speed = min(5.0, self._replay_speed * 1.25)
logger.info(f"Replay speed: {self._replay_speed:.2f}x")
def _decrease_replay_speed(self):
"""Decrease replay speed""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active:
self._replay_speed = max(0.1, self._replay_speed * 0.8)
logger.info(f"Replay speed: {self._replay_speed:.2f}x")
def _toggle_reverse_play(self):
"""Toggle reverse playback mode""" # noqa: D415
if hasattr(self, "_replay_active") and self._replay_active:
self._replay_reverse = not self._replay_reverse
direction = "REVERSE" if self._replay_reverse else "FORWARD"
logger.info(f"Replay direction: {direction}")
def _update_replay_frame(self):
"""Update all robot states to match the current replay frames""" # noqa: D415
if not hasattr(self, "_replay_active") or not self._replay_active:
return
# Get motion data at current time steps for all environments
root_pos = self._motion_lib.get_root_pos_w(self._replay_motion_ids, self._replay_time_steps)
root_quat = self._motion_lib.get_root_quat_w(
self._replay_motion_ids, self._replay_time_steps
)
root_lin_vel = self._motion_lib.get_root_lin_vel_w(
self._replay_motion_ids, self._replay_time_steps
)
root_ang_vel = self._motion_lib.get_root_ang_vel_w(
self._replay_motion_ids, self._replay_time_steps
)
motion_lib_joint_pos = self._motion_lib.get_dof_pos(
self._replay_motion_ids, self._replay_time_steps
)
motion_lib_joint_vel = self._motion_lib.get_dof_vel(
self._replay_motion_ids, self._replay_time_steps
)
# Handle DOF mismatch between motion library (e.g., 29 DOF) and robot (e.g., 43 DOF)
robot_num_joints = self.motion_command.robot.num_joints
motion_lib_num_dof = motion_lib_joint_pos.shape[-1]
if robot_num_joints > motion_lib_num_dof and self._body_joint_indices is not None:
# Robot has more DOFs than motion lib (e.g., 43 DOF robot with 29 DOF motion data)
# Use body joint indices for proper mapping
num_envs = motion_lib_joint_pos.shape[0]
# Create full joint tensors with zeros for all DOFs
joint_pos = torch.zeros(
num_envs, robot_num_joints, device=self.device, dtype=motion_lib_joint_pos.dtype
)
joint_vel = torch.zeros(
num_envs, robot_num_joints, device=self.device, dtype=motion_lib_joint_vel.dtype
)
# Map motion lib data to body joint indices (using G1_ISAACLab_ORDER mapping)
joint_pos[:, self._body_joint_indices] = motion_lib_joint_pos
joint_vel[:, self._body_joint_indices] = motion_lib_joint_vel
# Use hand DOFs from motion lib if available, otherwise default to zero
hand_dof_pos = self._motion_lib.get_hand_dof_pos(
self._replay_motion_ids, self._replay_time_steps
)
if hand_dof_pos is not None:
# Hand DOFs are the last N joints (in Isaac order, not G1_HAND_JOINTS order)
num_hand_dof = hand_dof_pos.shape[-1]
joint_pos[:, -num_hand_dof:] = hand_dof_pos
else:
joint_pos = motion_lib_joint_pos
joint_vel = motion_lib_joint_vel
# Add environment origin offsets (use custom grid origins if set)
if hasattr(self, "_replay_custom_origins"):
root_pos = root_pos + self._replay_custom_origins[self._replay_env_ids]
else:
root_pos = root_pos + self.env.scene.env_origins[self._replay_env_ids]
# Write state to simulation for all environments
self.motion_command.robot.write_joint_state_to_sim(
joint_pos, joint_vel, env_ids=self._replay_env_ids
)
self.motion_command.robot.write_root_state_to_sim(
torch.cat([root_pos, root_quat, root_lin_vel, root_ang_vel], dim=-1),
env_ids=self._replay_env_ids,
)
# Get object motion data from motion library
if hasattr(self._motion_lib, "_motion_object_root_pos") and hasattr(
self._motion_lib, "_motion_object_root_quat"
):
object_root_pos = self._motion_lib.get_object_root_pos(
self._replay_motion_ids, self._replay_time_steps
)
object_root_quat = self._motion_lib.get_object_root_quat(
self._replay_motion_ids, self._replay_time_steps
)
# Add environment origin offsets to object position
if hasattr(self, "_replay_custom_origins"):
object_root_pos = object_root_pos + self._replay_custom_origins[
self._replay_env_ids
].unsqueeze(1)
else:
object_root_pos = object_root_pos + self.env.scene.env_origins[
self._replay_env_ids
].unsqueeze(1)
# Write object state to simulation (handle multiple objects)
# Shape: object_root_pos is (num_envs, max_num_objects, 3)
# Shape: object_root_quat is (num_envs, max_num_objects, 4)
for obj_idx in range(object_root_pos.shape[1]):
obj_pos = object_root_pos[:, obj_idx, :]
obj_quat = object_root_quat[:, obj_idx, :]
object_root_pose = torch.cat([obj_pos, obj_quat], dim=-1)
self.env.scene["object"].write_root_pose_to_sim(
object_root_pose, env_ids=self._replay_env_ids
)
if hasattr(self.env, "scene") and "table" in self.env.scene.rigid_objects:
# Use pre-loaded per-motion table metadata (loaded during setup_replay_motion)
# _replay_table_pos and _replay_table_quat have shape (num_envs, 3) and (num_envs, 4)
if self._replay_table_pos is not None:
# Use per-env table positions directly (already the right shape)
table_pos = self._replay_table_pos.clone()
table_quat = self._replay_table_quat.clone()
else:
# Fallback: derive from object position with hardcoded offset
table_pos = self._motion_lib.get_object_root_pos(
self._replay_motion_ids, torch.zeros_like(self._replay_time_steps)
)[:, 0].clone()
table_pos[:, 2] = 0.76 # Table height
table_pos[:, 1] -= 0.15
table_quat = torch.tensor([[1.0, 0.0, 0.0, 0.0]], device=self.device).repeat(
len(self._replay_env_ids), 1
)
# Apply table_offset if configured (for fallback path)
table_offset = self.config.get("table_offset", None)
if table_offset is not None:
offset_tensor = torch.tensor(
table_offset, device=self.device, dtype=table_pos.dtype
)
table_pos = table_pos + offset_tensor
# Add environment origin offsets (same as robot and object)
if hasattr(self, "_replay_custom_origins"):
table_pos = table_pos + self._replay_custom_origins[self._replay_env_ids]
else:
table_pos = table_pos + self.env.scene.env_origins[self._replay_env_ids]
table_root_pose = torch.cat([table_pos, table_quat], dim=-1)
self.env.scene["table"].write_root_pose_to_sim(
table_root_pose, env_ids=self._replay_env_ids
)
# Visualize contact points if enabled
if (
hasattr(self, "_replay_contact_visualizer")
and self._replay_contact_visualizer is not None
and hasattr(self, "_replay_vis_enabled")
and self._replay_vis_enabled
):
self._update_contact_center_visualization()
self.env.sim.forward()
def step_replay(self):
"""Step the replay forward for all environments. Call this in a loop to animate the motions.
Returns False when replay is complete.
""" # noqa: D205
if not hasattr(self, "_replay_active") or not self._replay_active:
return False
# Update frames based on speed and direction
if not self._replay_paused:
if self._replay_reverse:
# Playing in reverse
self._replay_time_steps -= int(self._replay_speed)
# Handle beginning of motions per environment
at_start = self._replay_time_steps < 0
if at_start.any():
if self._replay_loop:
# Loop to end when reaching start
self._replay_time_steps[at_start] = (
self._replay_num_steps_per_env[at_start] - 1
).to(torch.long)
else:
# If any motion reached start and not looping, end replay
logger.info("Replay complete (reversed to start)!")
self._replay_active = False
return False
else:
# Playing forward (normal)
self._replay_time_steps += int(self._replay_speed)
# Handle end of motions per environment
completed = self._replay_time_steps >= self._replay_num_steps_per_env
if completed.any():
if self._replay_loop:
# Loop completed motions
self._replay_time_steps[completed] = 0
# Decrement loop counter if it's an integer (countdown mode)
if not isinstance(self._replay_loop, bool):
self._replay_loop -= 1
logger.info(f"Loop completed, {self._replay_loop} loops remaining")
if self._replay_loop <= 0:
logger.info("All loops complete!")
self._replay_active = False
return False
else:
# If any motion is complete and not looping, end replay
logger.info("Replay complete!")
self._replay_active = False
return False
# Update the robot states
self._update_replay_frame()
# Print progress every 50 frames (based on max time step)
max_time_step = self._replay_time_steps.max().item()
if max_time_step % 50 == 0 and max_time_step > 0:
progress = (max_time_step / self._replay_max_num_steps) * 100
logger.info(
f"Progress: {progress:.1f}% (max frame {max_time_step}/{self._replay_max_num_steps})"
)
return True