Update Wall-X to 1.1.0 (#104)

This commit is contained in:
Starrick Liu
2026-06-15 11:40:00 +08:00
committed by GitHub
parent e23a586846
commit 72834e7de5
200 changed files with 33916 additions and 16771 deletions
@@ -0,0 +1,3 @@
from .websocket_policy_server import WebsocketPolicyServer, BasePolicy
__all__ = ["WebsocketPolicyServer", "BasePolicy"]
@@ -0,0 +1 @@
"""Wall-X inference helpers vendored for harrix serving."""
@@ -0,0 +1,476 @@
from wall_x._vendor.harrix.serving._wallx_infer.infer_config import InferConfig
from typing import Optional, List
from dataclasses import dataclass, field
import numpy as np
import torch
import wall_x._vendor.x2robot_utils.geometry as data_utils
from wall_x._vendor.harrix.serving._wallx_infer.logger import InferLogger
dof_dims = {
"left_ee_cartesian_pos": 3,
"left_ee_cartesian_pos_relative": 3,
"left_ee_rotation": 3,
"left_ee_rotation_relative": 3,
"left_ee_rotation_6D": 6,
"left_ee_rotation_6D_relative": 6,
"left_arm_joint_pos": 7, # 6 joint + 1 gripper
"left_gripper": 1,
"left_gripper_cur": 1,
"left_arm_joint_cur": 7, # 1 -> 7
"right_ee_cartesian_pos": 3,
"right_ee_cartesian_pos_relative": 3,
"right_ee_rotation": 3,
"right_ee_rotation_relative": 3,
"right_ee_rotation_6D": 6,
"right_ee_rotation_6D_relative": 6,
"right_arm_joint_pos": 7, # 6 joint + 1 gripper
"right_gripper": 1,
"right_gripper_cur": 1,
"right_arm_joint_cur": 7, # 1 -> 7
"head_actions": 2,
"height": 1,
"car_pose": 3,
"velocity_decomposed": 3,
"velocity_decomposed_odom": 3,
"head_rotation": 2, # match ex001
"left_joint": 6,
"left_joint_gripper": 1,
"right_joint": 6,
"right_joint_gripper": 1,
"left_rotation_quat": 4,
"right_rotation_quat": 4,
"left_quaternion": 4,
"right_quaternion": 4,
"left_wrench_ext_local_force": 3,
"left_wrench_ext_local_torque": 3,
"right_wrench_ext_local_force": 3,
"right_wrench_ext_local_torque": 3,
"left_wrench_ext_local_force_from_joint": 3,
"left_wrench_ext_local_torque_from_joint": 3,
"right_wrench_ext_local_force_from_joint": 3,
"right_wrench_ext_local_torque_from_joint": 3,
"left_wrench_ext_world_force": 3,
"left_wrench_ext_world_torque": 3,
"left_wrench_ext_world_force_from_joint": 3,
"left_wrench_ext_world_torque_from_joint": 3,
"right_wrench_ext_world_force": 3,
"right_wrench_ext_world_torque": 3,
"right_wrench_ext_world_force_from_joint": 3,
"right_wrench_ext_world_torque_from_joint": 3,
"left_arm_joint_dev": 7,
"right_arm_joint_dev": 7,
}
class ComputedDict(dict):
"""Dict that registers compute rules and auto-computes None values on get"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._compute_rules = {} # key -> compute_function
def register_compute_rule(self, key, compute_func):
"""
Register a compute rule
Args:
key: Key to compute
compute_func: Callable taking self, returns computed value
"""
self._compute_rules[key] = compute_func
def get(self, key, default=None):
"""Override get to support auto-compute"""
value = super().get(key, default)
# If value is None and a rule exists, try to compute
if value is None and key in self._compute_rules:
try:
computed_value = self._compute_rules[key](self)
if computed_value is not None:
# Cache computed value
self[key] = computed_value
return computed_value
except Exception:
pass # On failure return None or default
return value if value is not None else default
def __getitem__(self, key):
"""Override [] to support auto-compute"""
value = super().get(key, None)
# If value is None and a rule exists, try to compute
if value is None and key in self._compute_rules:
try:
computed_value = self._compute_rules[key](self)
if computed_value is not None:
# Cache computed value
self[key] = computed_value
return computed_value
except Exception:
pass # On failure raise KeyError or return None
if key in self:
return super().__getitem__(key)
raise KeyError(key)
@dataclass
class RobotStateActionData:
config: InferConfig = None
data: ComputedDict = field(
default_factory=lambda: ComputedDict(
{
# State (formerly pose) - state_ prefix
"state_left_ee_cartesian_pos": None, # (1, 3)
"state_left_ee_rotation": None, # (1, 3)
"state_left_ee_rotation_6D": None,
"state_left_arm_joint_pos": None,
"state_left_gripper": None, # (1, 1)
"state_left_arm_joint_cur": None,
"state_left_gripper_cur": None,
"state_right_ee_cartesian_pos": None, # (1, 3)
"state_right_ee_rotation": None,
"state_right_ee_rotation_6D": None, # (1, 6)
"state_right_arm_joint_pos": None,
"state_right_gripper": None,
"state_right_gripper_cur": None,
"state_right_arm_joint_cur": None, # (1, 1)
"state_head_actions": None,
"state_head_rotation": None, # match ex001
"state_height": None,
"state_car_pose": None,
"state_velocity_decomposed": None,
"state_velocity_decomposed_odom": None,
# support joint control
"state_left_joint": None,
"state_left_joint_gripper": None,
"state_right_joint": None,
"state_right_joint_gripper": None,
# support quaternion control
"state_left_quaternion": None,
"state_right_quaternion": None,
"state_left_rotation_quat": None,
"state_right_rotation_quat": None,
# support wrench/force-torque observation
"state_left_wrench_ext_local_force": None,
"state_left_wrench_ext_local_torque": None,
"state_right_wrench_ext_local_force": None,
"state_right_wrench_ext_local_torque": None,
"state_left_wrench_ext_local_force_from_joint": None,
"state_left_wrench_ext_local_torque_from_joint": None,
"state_right_wrench_ext_local_force_from_joint": None,
"state_right_wrench_ext_local_torque_from_joint": None,
"state_left_wrench_ext_world_force": None,
"state_left_wrench_ext_world_torque": None,
"state_left_wrench_ext_world_force_from_joint": None,
"state_left_wrench_ext_world_torque_from_joint": None,
"state_right_wrench_ext_world_force": None,
"state_right_wrench_ext_world_torque": None,
"state_right_wrench_ext_world_force_from_joint": None,
"state_right_wrench_ext_world_torque_from_joint": None,
# support joint deviation
"state_left_arm_joint_dev": None,
"state_right_arm_joint_dev": None,
# Action - action_ prefix
"action_left_ee_cartesian_pos": None,
"action_left_ee_cartesian_pos_relative": None,
"action_left_ee_rotation": None,
"action_left_ee_rotation_relative": None,
"action_left_ee_rotation_6D": None,
"action_left_ee_rotation_6D_relative": None,
"action_left_gripper": None,
"action_left_arm_joint_pos": None,
"action_right_ee_cartesian_pos": None,
"action_right_ee_cartesian_pos_relative": None,
"action_right_ee_rotation": None,
"action_right_ee_rotation_relative": None,
"action_right_ee_rotation_6D": None,
"action_right_ee_rotation_6D_relative": None,
"action_right_gripper": None,
"action_right_arm_joint_pos": None,
"action_head_actions": None,
"action_head_rotation": None, # match ex001
"action_height": None,
"action_car_pose": None,
"action_velocity_decomposed": None,
"action_velocity_decomposed_odom": None,
# support joint control
"action_left_joint": None,
"action_left_joint_gripper": None,
"action_right_joint": None,
"action_right_joint_gripper": None,
# support quaternion control
"action_left_quaternion": None,
"action_right_quaternion": None,
"action_left_rotation_quat": None,
"action_right_rotation_quat": None,
}
)
)
dof_mask: np.ndarray = None
logger = InferLogger.get_robot_logger("RobotStateActionData")
def __post_init__(self):
"""Register compute rules"""
# State rules: euler angles -> 6D rotation
self.data.register_compute_rule(
"state_left_ee_rotation_6D",
lambda d: (
data_utils.euler_to_matrix_zyx_6d_nb(d["state_left_ee_rotation"])
if d.get("state_left_ee_rotation") is not None
else None
),
)
self.data.register_compute_rule(
"state_right_ee_rotation_6D",
lambda d: (
data_utils.euler_to_matrix_zyx_6d_nb(d["state_right_ee_rotation"])
if d.get("state_right_ee_rotation") is not None
else None
),
)
# Action rules: absolute position from relative + state
self.data.register_compute_rule(
"action_left_ee_cartesian_pos",
lambda d: (
d.get("state_left_ee_cartesian_pos")
+ d.get("action_left_ee_cartesian_pos_relative")
if d.get("state_left_ee_cartesian_pos") is not None
and d.get("action_left_ee_cartesian_pos_relative") is not None
else None
),
)
self.data.register_compute_rule(
"action_right_ee_cartesian_pos",
lambda d: (
d.get("state_right_ee_cartesian_pos")
+ d.get("action_right_ee_cartesian_pos_relative")
if d.get("state_right_ee_cartesian_pos") is not None
and d.get("action_right_ee_cartesian_pos_relative") is not None
else None
),
)
# Action rules: compute absolute rpy
self.data.register_compute_rule( # delta rpy -> abs rpy
"action_left_ee_rotation",
lambda d: (
data_utils.compose_state_and_delta_to_abs_rpy(
d["action_left_ee_rotation_relative"],
d["state_left_ee_rotation"][0],
)
if d.get("action_left_ee_rotation_relative") is not None
and d.get("state_left_ee_rotation") is not None
else None
),
)
self.data.register_compute_rule( # abs 6D -> abs rpy
"action_left_ee_rotation",
lambda d: (
data_utils.so3_to_euler_zyx_batch_nb(d["action_left_ee_rotation_6D"])
if d.get("action_left_ee_rotation_6D") is not None
else None
),
)
self.data.register_compute_rule( # delta 6D -> abs 6D
"action_left_ee_rotation_6D",
lambda d: (
data_utils.compose_state_and_delta_to_abs_6d(
d["action_left_ee_rotation_6D_relative"],
d["state_left_ee_rotation_6D"][0],
)
if d.get("action_left_ee_rotation_6D_relative") is not None
and d.get("state_left_ee_rotation_6D") is not None
else None
),
)
self.data.register_compute_rule( # delta rpy -> abs rpy
"action_right_ee_rotation",
lambda d: (
data_utils.compose_state_and_delta_to_abs_rpy(
d["action_right_ee_rotation_relative"],
d["state_right_ee_rotation"][0],
)
if d.get("action_right_ee_rotation_relative") is not None
and d.get("state_right_ee_rotation") is not None
else None
),
)
self.data.register_compute_rule( # abs 6D -> abs rpy
"action_right_ee_rotation",
lambda d: (
data_utils.so3_to_euler_zyx_batch_nb(d["action_right_ee_rotation_6D"])
if d.get("action_right_ee_rotation_6D") is not None
else None
),
)
self.data.register_compute_rule( # delta 6D -> abs 6D
"action_right_ee_rotation_6D",
lambda d: (
data_utils.compose_state_and_delta_to_abs_6d(
d["action_right_ee_rotation_6D_relative"],
d["state_right_ee_rotation_6D"][0],
)
if d.get("action_right_ee_rotation_6D_relative") is not None
and d.get("state_right_ee_rotation_6D") is not None
else None
),
)
def get_agent_pos(self, obs_action_keys=None):
if obs_action_keys is None:
obs_action_keys = self.config.train_config["agent_pos_config"].keys()
agent_pose_data = []
for key in obs_action_keys:
# action_padding is a virtual key used to pad agent_pos width.
if key == "action_padding":
dim = self.config.train_config["agent_pos_config"][key]
agent_pose_data.append(np.zeros((1, dim)))
continue
# Strip follow_ or master_ prefix
if key.startswith("follow_"):
key = key.replace("follow_", "")
elif key.startswith("master_"):
key = key.replace("master_", "")
# Add state_ prefix to access state data
state_key = f"state_{key}"
if state_key in self.data:
# get() auto-computes when value is None
value = self.data.get(state_key)
if value is None:
# If still None after compute, use zeros
agent_pose_data.append(np.zeros((1, dof_dims[key])))
else:
agent_pose_data.append(value)
else:
raise ValueError(f"Key {state_key} not found in data")
agent_pose_data = np.concatenate(agent_pose_data, axis=1)[None] # (1, 1, D)
return agent_pose_data
def get_agent_pos_mask(self, obs_action_keys=None):
if obs_action_keys is None:
obs_action_keys = self.config.train_config["agent_pos_config"].keys()
agent_pos_mask_data = []
for key in obs_action_keys:
# action_padding carries no information and should stay masked out.
if key == "action_padding":
dim = self.config.train_config["agent_pos_config"][key]
agent_pos_mask_data.append(np.zeros((1, dim)))
continue
# Strip follow_ or master_ prefix
if key.startswith("follow_"):
key = key.replace("follow_", "")
elif key.startswith("master_"):
key = key.replace("master_", "")
# Add state_ prefix to access state data
state_key = f"state_{key}"
if state_key in self.data:
# get() auto-computes when value is None
value = self.data.get(state_key)
if value is None:
agent_pos_mask_data.append(np.zeros((1, dof_dims[key])))
else:
agent_pos_mask_data.append(np.ones((1, value.shape[1])))
else:
raise ValueError(f"Key {state_key} not found in data")
return np.concatenate(agent_pos_mask_data, axis=1)[None] # (1, 1, D)
def save_state_data_with_key(self, value, key, gt_dim=None):
# Strip follow_ or master_ prefix
key = key.replace("follow_", "")
key = key.replace("master_", "")
# if torch, convert to numpy
if isinstance(value, torch.Tensor):
value = value.detach().cpu().numpy()
if f"state_{key}" not in self.data: # TODO: joint angle control
self.logger.warning(f"{key} is not a valid state key; not recorded")
return
gt_dim = dof_dims[key] if gt_dim is None else gt_dim
# Shape check: expected (1, D)
if value.shape == (1, gt_dim):
self.data[f"state_{key}"] = value
elif value.shape == (1, 1, gt_dim):
self.data[f"state_{key}"] = value[0]
elif value.shape == (gt_dim,):
self.data[f"state_{key}"] = value[None]
else:
raise ValueError(f"Value shape {value.shape} is not legal")
def save_action_data_with_key(self, value, key):
key = key.replace("follow_", "")
key = key.replace("master_", "")
if isinstance(value, torch.Tensor):
value = value.detach().cpu().numpy()
if value.shape == (dof_dims[key],):
self.data[f"action_{key}"] = value[None]
else:
self.data[f"action_{key}"] = value
def save_action_data(
self, predict_action, predict_action_keys: Optional[List[str]] = None
):
if predict_action_keys is None:
predict_action_keys = getattr(
self.config.data_config, "predict_action_keys", None
)
if predict_action_keys is None:
try:
predict_action_keys = self.config.data_config["predict_action_keys"]
except (KeyError, AttributeError):
predict_action_keys = list(
self.config.train_config["dof_config"].keys()
)
if isinstance(predict_action, torch.Tensor):
predict_action = predict_action.detach().cpu().numpy()
if predict_action.ndim == 3:
predict_action = predict_action[0]
dof_start = 0
for action_key in predict_action_keys:
# action_padding is a virtual key that only advances dof_start; no data is written.
if action_key == "action_padding":
dof_dim = self.config.train_config["dof_config"]["action_padding"]
dof_start += dof_dim
continue
action_key = action_key.replace("follow_", "")
action_key = action_key.replace("master_", "")
dof_dim = dof_dims[action_key]
action_key = f"action_{action_key}"
self.data[action_key] = predict_action[:, dof_start : dof_start + dof_dim]
dof_start += dof_dim
# Convenience properties for compatibility
@property
def agent_pos(self):
return self.get_agent_pos()
@property
def agent_pos_mask(self):
return self.get_agent_pos_mask()
@property
def action(self):
pass # TODO: support action access
@@ -0,0 +1,434 @@
import logging
import os
from dataclasses import dataclass, field
from typing import Any
import yaml
from qwen_vl_utils.vision_process import IMAGE_FACTOR, MAX_PIXELS, MIN_PIXELS
logger = logging.getLogger(__name__)
def _env_bool(name: str, default: bool) -> bool:
v = os.environ.get(name)
if v is None:
return default
return v.strip().lower() in ("1", "true", "yes", "on")
@dataclass
class InferenceDataConfig:
"""Minimal data config needed by online inference.
Serving does not build datasets, so it should not require closed-source
data backends just to read image resize settings from a checkpoint yaml.
"""
resolution: dict[str, int] = field(default_factory=dict)
model_type: str = "qwen2_5"
max_pixels: int = MAX_PIXELS
min_pixels: int = MIN_PIXELS
image_factor: int = IMAGE_FACTOR
use_relative_action: bool = False
predict_action_keys: list[str] = field(default_factory=list)
def __getitem__(self, key: str) -> Any:
if not hasattr(self, key):
raise KeyError(key)
return getattr(self, key)
class InferConfig:
def __init__(
self,
checkpoint_path: str | None = None,
train_config_path: str | None = None,
robot_host: str = "0.0.0.0",
robot_port: int = 41776,
robot_type: str = "desktop", # ["desktop", "turtle", "ex001"]
robot_action_start_ratio: float = 0, # Start ratio for trimming executed actions
robot_action_end_ratio: float = 0.8, # End ratio for trimming executed actions
robot_action_interpolate_multiplier: int = 10, # Action interpolation multiplier
robot_use_joint_angle_control: bool = False, # Joint control (model must predict joints)
turtle_as_desktop: bool = False, # Use turtle as desktop: fixed base/head motion, head camera, base height
action_horizon: int = 32, # Set to the model's action horizon
action_dim: int | None = None,
ar_action_dim: int | None = None,
model_device: str = "cuda:0",
num_inference_timesteps: int = 10,
num_inference_steps: int | None = None,
cfg_scale: float | None = None,
seed: int | None = None,
norm_key: str = "x2_normal", # ["x2_normal", "ex_normal"]
cam_names: list[str] | None = None,
camera_front_key: str = "camera_front",
camera_left_key: str = "camera_left",
camera_right_key: str = "camera_right",
default_instruction: str | None = None,
prompt_template: str | None = None,
qwen25_prompt_template: str | None = None,
prompt_priority_order: str | None = None,
save_video_dir: str = "./videos",
robot_id: str = "10000",
model_type: str = "wallx", # ["wallx", "vga"]
smooth_action: bool = False,
smooth_gripper: bool = True,
):
# Private attributes for paths
assert checkpoint_path is not None
from wall_x._vendor.harrix.utils.ckpt_load import resolve_checkpoint_dir
if not os.path.isdir(checkpoint_path) and not os.path.isfile(checkpoint_path):
raise FileNotFoundError(
f"Checkpoint path not found: {checkpoint_path!r}. "
"Serving requires a directory containing model.safetensors and "
"normalizer_action.pth / normalizer_propri.pth (or norm_stats.json)."
)
self._checkpoint_path = resolve_checkpoint_dir(checkpoint_path)
action_pth = os.path.join(self._checkpoint_path, "normalizer_action.pth")
propri_pth = os.path.join(self._checkpoint_path, "normalizer_propri.pth")
if os.path.exists(action_pth):
self.normalizer_action_path = action_pth
if os.path.exists(propri_pth):
self.normalizer_propri_path = propri_pth
# Other config attributes
self.robot_host = robot_host
self.robot_port = robot_port
self.robot_type = robot_type # ["desktop", "turtle", "ex001"]
self.robot_action_start_ratio = robot_action_start_ratio
self.robot_action_end_ratio = robot_action_end_ratio
self.robot_action_interpolate_multiplier = robot_action_interpolate_multiplier
self.robot_use_joint_angle_control = (
robot_use_joint_angle_control # Joint-angle control
)
self.turtle_as_desktop = turtle_as_desktop
self.robot_id = robot_id
self._action_horizon = (
action_horizon # Default from train config flow action horizon
)
self._action_dim = action_dim # Default from train config dof config
self.ar_action_dim = ar_action_dim # Default from train config ar dof config
self.model_device = model_device
self.num_inference_timesteps = (
num_inference_timesteps # flow matching related config
)
self.num_inference_steps = num_inference_steps
self.cfg_scale = cfg_scale
self.seed = seed
self.save_video_dir = save_video_dir
self.model_type = model_type # ["wallx", "vga"]
# Initialize config objects
self.train_config: dict = {}
self.model_config = None
self.data_config = None
self.norm_key = norm_key
self.cam_names = cam_names or [
"face_view",
"left_wrist_view",
"right_wrist_view",
]
self.camera_front_key = camera_front_key
self.camera_left_key = camera_left_key
self.camera_right_key = camera_right_key
self.default_instruction = default_instruction
self.prompt_template = prompt_template
self.qwen25_prompt_template = qwen25_prompt_template
self.prompt_priority_order = prompt_priority_order
self.smooth_action = _env_bool("WALLX_SMOOTH_ACTION", smooth_action)
self.smooth_gripper = _env_bool("WALLX_SMOOTH_GRIPPER", smooth_gripper)
# Load all configs
self._load_all_configs(train_config_path)
self._apply_cam_names_from_train_config(cam_names)
def _apply_cam_names_from_train_config(
self, cam_names: list[str] | None
) -> None:
"""Use train YAML camera mapping when CLI did not override cam_names."""
if cam_names is not None:
return
from wall_x._vendor.harrix.utils.train_config import (
resolve_cam_names_from_train_config,
)
resolved = resolve_cam_names_from_train_config(self.train_config)
if resolved:
self.cam_names = resolved
logger.info(
"[InferConfig] cam_names from train key_mappings: %s",
self.cam_names,
)
@property
def checkpoint_path(self) -> str | None:
return self._checkpoint_path
@checkpoint_path.setter
def checkpoint_path(self, value: str | None):
"""Reload all configs when checkpoint_path is updated"""
if self._checkpoint_path != value:
self._checkpoint_path = value
self._load_all_configs()
@property
def action_horizon(self) -> int:
return self._action_horizon
@action_horizon.setter
def action_horizon(self, value: int):
self._action_horizon = value
@property
def action_dim(self) -> int | None:
return self._action_dim
@action_dim.setter
def action_dim(self, value: int | None):
self._action_dim = value
@property
def ar_action_dim(self) -> int | None:
return self._ar_action_dim
@ar_action_dim.setter
def ar_action_dim(self, value: int | None):
self._ar_action_dim = value
def _load_all_configs(self, train_config_path=None):
"""Unified entry to load all configs"""
self._load_train_config(train_config_path)
if self.model_type != "vga":
self._load_model_config_for_wallx()
self._load_data_config()
# Update action_horizon and action_dim if needed
if self._action_horizon is None:
self._action_horizon = self.train_config.get("data", {}).get(
"action_horizon_flow", 32
)
assert self._action_horizon is not None and self._action_horizon > 0
if self._action_dim is None:
dof_config = (
self.train_config.get("dof_config")
or self.train_config.get("task", {}).get("dof_config")
or self.train_config.get("data", {}).get("dof_config", {})
)
self._action_dim = sum(dof_config.values())
if self._ar_action_dim is None:
ar_dof_config = self.train_config.get("ar_dof_config") or self.train_config.get(
"task", {}
).get("ar_dof_config", {})
self._ar_action_dim = sum(ar_dof_config.values())
def _load_train_config(self, train_config_path):
if train_config_path is None:
for fname in ("config.yml", "config.yaml"):
candidate = os.path.join(self._checkpoint_path, fname)
if os.path.exists(candidate):
train_config_path = candidate
break
else:
raise FileNotFoundError(
f"No config.yml/config.yaml found in {self._checkpoint_path}"
)
with open(train_config_path, "r") as f:
self.train_config = yaml.load(f, Loader=yaml.FullLoader)
ckpt_dir = self._checkpoint_path
preprocessor_file = os.path.join(ckpt_dir, "preprocessor_config.json")
if os.path.exists(preprocessor_file):
logger.info(
"[LoadConfig] Found %s, override processor_path.",
preprocessor_file,
)
self.train_config["processor_path"] = ckpt_dir
tokenizer_file = os.path.join(ckpt_dir, "tokenizer.json")
tokenizer_config_file = os.path.join(ckpt_dir, "tokenizer_config.json")
if self.train_config.get(
"action_tokenizer_path", None
) is not None and not os.path.exists(
self.train_config.get("action_tokenizer_path", None)
):
if os.path.exists(tokenizer_file) and os.path.exists(tokenizer_config_file):
logger.info(
"[LoadConfig] Found tokenizer files in %s, override action_tokenizer_path.",
ckpt_dir,
)
self.train_config["action_tokenizer_path"] = ckpt_dir
else:
logger.warning("[LoadConfig] Cannot load action tokenizer! ")
from wall_x._vendor.harrix.utils.train_config import (
normalize_train_config_for_inference,
strip_action_tokenizer_fields,
)
self.train_config = normalize_train_config_for_inference(
self.train_config, train_config_path
)
# Flow serving does not load AR action tokenizers; crop embed_tokens instead.
strip_action_tokenizer_fields(self.train_config)
self._train_config_path = train_config_path
def _load_model_config_for_wallx(self):
model_type = self.train_config["model_type"]
if not os.path.isdir(self._checkpoint_path):
return
# For Qwen models
ckpt_config_path = os.path.join(self._checkpoint_path, "config.json")
resolved_cfg_path = None
if os.path.exists(ckpt_config_path):
# Prefer checkpoint config
resolved_cfg_path = ckpt_config_path
logger.info(
"[LoadModelConfig] Using checkpoint config.json: %s",
ckpt_config_path,
)
else:
# Fallback to original config path
fallback_cfg = self.train_config.get("qwen_vl_act_config_path", None)
if fallback_cfg is not None:
resolved_cfg_path = fallback_cfg
logger.info(
"[LoadModelConfig] Using fallback act config: %s",
fallback_cfg,
)
if resolved_cfg_path is None or (not os.path.exists(resolved_cfg_path)):
raise ValueError(
f"[LoadModelConfig] Cannot load model config! "
f"Checked:\n"
f" - Checkpoint config.json: {ckpt_config_path}\n"
f" - Fallback path: {self.train_config.get('qwen_vl_act_config_path', None)}"
)
# Save back to config for consistency
self.train_config["qwen_vl_act_config_path"] = resolved_cfg_path
from wall_x.trainer.adapters import resolve_adapter
adapter_cls = resolve_adapter(model_type)
ConfigClass = adapter_cls.config_class()
logger.info(
"[LoadModelConfig] Loading model config from: %s", resolved_cfg_path
)
if resolved_cfg_path.endswith(".json"):
self.model_config = ConfigClass.from_json_file(resolved_cfg_path)
else:
self.model_config = ConfigClass.from_pretrained(resolved_cfg_path)
self.model_config.update_model_config(self.train_config)
self.model_config._attn_implementation = "sdpa"
vision_attn = os.environ.get("WALLX_VISION_ATTN_IMPLEMENTATION", "flash_attention_2")
self.model_config.vision_config._attn_implementation = vision_attn
logger.info(
"[LoadModelConfig] vision _attn_implementation=%s (override via WALLX_VISION_ATTN_IMPLEMENTATION)",
vision_attn,
)
logger.info("[LoadModelConfig] Model config loaded and updated successfully.")
def _load_data_config(self):
# Prefer typed TrainConfig path (handles new 8-section schema where
# dof_config lives under task:). Fall back to legacy raw-dict path
# for old flat yamls. See trainer/adapters/base_adapter.py.
typed_dcfg = self._try_typed_data_config()
if typed_dcfg is not None:
self.data_config = typed_dcfg
elif self.model_type != "vga":
self.data_config = self._build_inference_data_config()
else:
# TEMPORARY: direct call to the private _set_data_backend.
# VGA inference still uses the legacy x2robot data config object.
# Wall-X online inference uses the lightweight InferenceDataConfig
# above so serving does not require excluded data backends.
from wall_x.data._registry import _set_data_backend
_set_data_backend(self.train_config.get("dataset_type", "x2robot_v1"))
from wall_x.model.vga.openloop_visualization import get_data_configs
dataload_config = get_data_configs(self.train_config.get("data", {}))
dataload_config["predict_action_keys"] = list(
dataload_config.get("dof_config", {}).keys()
)
self.data_config = dataload_config
self._ensure_predict_action_keys()
def _ensure_predict_action_keys(self) -> None:
dof_keys = list((self.train_config.get("dof_config") or {}).keys())
if not dof_keys:
return
if isinstance(self.data_config, InferenceDataConfig):
if not self.data_config.predict_action_keys:
self.data_config.predict_action_keys = dof_keys
elif isinstance(self.data_config, dict):
self.data_config.setdefault("predict_action_keys", dof_keys)
def _build_inference_data_config(self) -> InferenceDataConfig:
data = self.train_config.get("data", {}) or {}
def get(key: str, default: Any) -> Any:
return data.get(key, self.train_config.get(key, default))
resolution = get("resolution", {}) or {}
dof_config = self.train_config.get("dof_config") or {}
return InferenceDataConfig(
resolution=dict(resolution),
model_type=get(
"model_type", self.train_config.get("model_type", "qwen2_5")
),
max_pixels=get("max_pixels", MAX_PIXELS),
min_pixels=get("min_pixels", MIN_PIXELS),
image_factor=get("image_factor", IMAGE_FACTOR),
use_relative_action=get("use_relative_action", False),
predict_action_keys=list(dof_config.keys()),
)
def _try_typed_data_config(self):
"""Attempt to load TrainConfig and build X2RDataConfig via typed path.
Returns the X2RDataConfig on success, or None if the yaml is not in
TrainConfig schema (legacy flat yaml).
"""
yml_path = getattr(self, "_train_config_path", None)
if yml_path is None:
for fname in ("config.yml", "config.yaml"):
candidate = os.path.join(self._checkpoint_path, fname)
if os.path.exists(candidate):
yml_path = candidate
break
if yml_path is None:
return None
try:
from wall_x.config.loader import load_config
from wall_x.trainer.adapters.base_adapter import load_trainer_data_config
typed_cfg = load_config(yml_path)
except Exception as e:
logger.warning(
"[InferConfig] Typed config load failed for %s: %s",
yml_path,
e,
)
return None
try:
return load_trainer_data_config(typed_cfg)
except Exception as e:
logger.warning(
"[InferConfig] Typed config load failed for %s: %s",
yml_path,
e,
)
return None
if __name__ == "__main__":
config = InferConfig()
logger.info("%s", config.train_config)
@@ -0,0 +1,277 @@
"""Layered logging helpers for inference components."""
import logging
import sys
from typing import Optional
from pathlib import Path
from datetime import datetime
try:
import colorlog
HAS_COLORLOG = True
except ImportError:
HAS_COLORLOG = False
logging.getLogger(__name__).warning(
"colorlog not installed. Install with: pip install colorlog"
)
class InferLogger:
"""
Layered inference logging system
"""
_loggers = {}
_initialized = False
# Layer identifiers
LEVEL_ENV = "ENV"
LEVEL_ROBOT = "ROBOT"
LEVEL_CONTROLLER = "CONTROLLER"
LEVEL_MODEL = "MODEL"
LEVEL_UTILS = "UTILS"
# Layer colors (terminal output)
LEVEL_COLORS = {
LEVEL_ENV: "cyan",
LEVEL_ROBOT: "green",
LEVEL_CONTROLLER: "yellow",
LEVEL_MODEL: "purple", # colorlog uses 'purple' not 'magenta'
LEVEL_UTILS: "blue",
}
@classmethod
def setup(
cls,
log_level: str = "INFO",
log_dir: Optional[str] = None,
console_output: bool = True,
file_output: bool = True,
colorful: bool = True,
):
"""
Initialize the logging system
Args:
log_level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_dir: Log file directory
console_output: Whether to log to console
file_output: Whether to log to file
colorful: Colored console output (requires colorlog)
"""
if cls._initialized:
return
cls.log_level = getattr(logging, log_level.upper())
cls.console_output = console_output
cls.file_output = file_output
cls.colorful = colorful and HAS_COLORLOG
# Create log directory
if file_output and log_dir:
cls.log_dir = Path(log_dir)
cls.log_dir.mkdir(parents=True, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
cls.log_file = cls.log_dir / f"infer_{timestamp}.log"
else:
cls.log_file = None
cls._initialized = True
@classmethod
def get_logger(cls, name: str, level: str = None) -> logging.Logger:
"""
Get a logger for the given layer
Args:
name: Logger name (usually module or class name)
level: Layer tag (ENV, ROBOT, CONTROLLER, MODEL, UTILS)
Returns:
Configured logger instance
"""
if not cls._initialized:
cls.setup()
# Auto-detect layer
if level is None:
level = cls._detect_level(name)
logger_key = f"{level}.{name}"
if logger_key in cls._loggers:
return cls._loggers[logger_key]
# Create new logger
logger = logging.getLogger(logger_key)
logger.setLevel(cls.log_level)
logger.propagate = False
# Clear existing handlers
logger.handlers.clear()
# Console output
if cls.console_output:
console_handler = logging.StreamHandler(sys.stdout)
console_handler.setLevel(cls.log_level)
if cls.colorful:
# Colored formatter
color = cls.LEVEL_COLORS.get(level, "white")
console_format = (
f"%(log_color)s[%(asctime)s]%(reset)s "
f"%(bold_{color})s[{level:^10}]%(reset)s "
f"%(bold_white)s[%(name)s]%(reset)s "
f"%(log_color)s%(levelname)-8s%(reset)s "
f"%(message)s"
)
console_formatter = colorlog.ColoredFormatter(
console_format,
datefmt="%H:%M:%S",
log_colors={
"DEBUG": "cyan",
"INFO": "green",
"WARNING": "yellow",
"ERROR": "red",
"CRITICAL": "bold_red",
},
)
else:
# Plain formatter
console_format = (
f"[%(asctime)s] [{level:^10}] [%(name)s] "
f"%(levelname)-8s %(message)s"
)
console_formatter = logging.Formatter(
console_format, datefmt="%H:%M:%S"
)
console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler)
# File output
if cls.file_output and cls.log_file:
file_handler = logging.FileHandler(cls.log_file, encoding="utf-8")
file_handler.setLevel(cls.log_level)
file_format = (
f"[%(asctime)s] [{level:^10}] [%(name)s] "
f"%(levelname)-8s %(message)s"
)
file_formatter = logging.Formatter(file_format, datefmt="%Y-%m-%d %H:%M:%S")
file_handler.setFormatter(file_formatter)
logger.addHandler(file_handler)
cls._loggers[logger_key] = logger
return logger
@classmethod
def _detect_level(cls, name: str) -> str:
"""Auto-detect layer from name"""
name_lower = name.lower()
if "env" in name_lower:
return cls.LEVEL_ENV
elif "robot" in name_lower and "controller" not in name_lower:
return cls.LEVEL_ROBOT
elif (
"controller" in name_lower
or "communication" in name_lower
or "socket" in name_lower
):
return cls.LEVEL_CONTROLLER
elif "model" in name_lower or "wrapper" in name_lower:
return cls.LEVEL_MODEL
else:
return cls.LEVEL_UTILS
@classmethod
def get_env_logger(cls, name: str = "Environment") -> logging.Logger:
"""Get ENV layer logger"""
return cls.get_logger(name, cls.LEVEL_ENV)
@classmethod
def get_robot_logger(cls, name: str = "Robot") -> logging.Logger:
"""Get ROBOT layer logger"""
return cls.get_logger(name, cls.LEVEL_ROBOT)
@classmethod
def get_controller_logger(cls, name: str = "Controller") -> logging.Logger:
"""Get CONTROLLER layer logger"""
return cls.get_logger(name, cls.LEVEL_CONTROLLER)
@classmethod
def get_model_logger(cls, name: str = "Model") -> logging.Logger:
"""Get MODEL layer logger"""
return cls.get_logger(name, cls.LEVEL_MODEL)
@classmethod
def get_utils_logger(cls, name: str = "Utils") -> logging.Logger:
"""Get UTILS layer logger"""
return cls.get_logger(name, cls.LEVEL_UTILS)
@classmethod
def set_level(cls, level: str):
"""Change log level for all loggers"""
new_level = getattr(logging, level.upper())
cls.log_level = new_level
for logger in cls._loggers.values():
logger.setLevel(new_level)
for handler in logger.handlers:
handler.setLevel(new_level)
@classmethod
def close_all(cls):
"""Close file handles for all loggers"""
for logger in cls._loggers.values():
for handler in logger.handlers[:]:
handler.close()
logger.removeHandler(handler)
cls._loggers.clear()
cls._initialized = False
# Convenience functions
def get_logger(name: str, level: str = None) -> logging.Logger:
"""
Convenience wrapper to get a logger
Args:
name: Logger name (usually __name__)
level: Layer tag (optional; auto-detected if omitted)
Returns:
Configured logger instance
Example:
from wall_x._vendor.harrix.serving._wallx_infer.logger import get_logger
logger = get_logger(__name__) # auto-detect layer
logger = get_logger(__name__, "ROBOT") # explicit layer
"""
return InferLogger.get_logger(name, level)
def setup_logger(
log_level: str = "INFO",
log_dir: Optional[str] = None,
console_output: bool = True,
file_output: bool = True,
colorful: bool = True,
):
"""
Convenience wrapper to configure logging
Args:
log_level: Log level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
log_dir: Log file directory
console_output: Whether to log to console
file_output: Whether to log to file
colorful: Colored console output
Example:
from wall_x._vendor.harrix.serving._wallx_infer.logger import setup_logger
setup_logger(log_level="DEBUG", log_dir="./logs")
"""
InferLogger.setup(log_level, log_dir, console_output, file_output, colorful)
@@ -0,0 +1,760 @@
import os
import torch
import copy
from safetensors.torch import load_file
import numpy as np
from PIL import Image
from qwen_vl_utils.vision_process import smart_resize
from transformers import BatchFeature
from wall_x.trainer.trainer_utils import load_wallx_processors
from wall_x._vendor.x2robot_utils.text_templates import (
preprocesser_call,
get_prologue_with_embodied_information,
)
from wall_x._vendor.x2robot_utils.grounding import (
reverse_grounding_points,
extract_grounding_points,
)
from wall_x._vendor.harrix.serving._wallx_infer.infer_config import InferConfig
from wall_x._vendor.harrix.utils.ckpt_load import reshape_compatible_state_dict
from wall_x._vendor.harrix.utils.train_config import (
resolve_camera_label,
resolve_max_length,
resolve_state_bins,
resolve_use_state_string_representation,
)
from wall_x._vendor.harrix.serving._wallx_infer.logger import InferLogger
from wall_x.utils.timers import timer, ScopeTimer
ENABLE_FAST_PREPROCESS = os.getenv("ENABLE_FAST_PREPROCESS", "False").lower() == "true"
def move_to_cuda(obj, device="cuda"):
if isinstance(obj, torch.Tensor):
return obj.to(device)
elif isinstance(obj, (dict, BatchFeature)):
return {k: move_to_cuda(v, device) for k, v in obj.items()}
elif isinstance(obj, list):
return [move_to_cuda(v, device) for v in obj]
elif isinstance(obj, tuple):
return tuple(move_to_cuda(v, device) for v in obj)
else:
return obj
class WallxModelWrapper:
def __init__(self, config: InferConfig):
self.config = config
self.logger = InferLogger.get_model_logger("WallxModelWrapper")
self.norm_key = self.config.norm_key
self._register_normalizers()
self.logger.info(f"normalizers {self.norm_key} registered")
self._load_processor()
self._load_model()
self.load_ckpt()
self.logger.info(f"model {self.config.checkpoint_path} loaded")
self.norm_key = self.config.norm_key
self._register_normalizers()
self.logger.info(f"normalizers {self.norm_key} registered")
# Initialize robot_type_id (for v3.1 delta tokenizer)
self.robot_type_id = None
if self.tokenizer_mixin is not None:
robot_type = getattr(self.config, "robot_type", None)
if robot_type:
self.tokenizer_mixin.init_inference(robot_type=robot_type)
if hasattr(self.tokenizer_mixin, "robot_type_id"):
self.robot_type_id = self.tokenizer_mixin.robot_type_id
if self.robot_type_id is not None:
self.logger.info(
f"Initialized robot_type_id: {self.robot_type_id} from robot_type: {robot_type}"
)
self.role_start_symbol = "<|im_start|>"
self.role_end_symbol = "<|im_end|>"
self.vision_start_symbol = "<|vision_start|>"
self.vision_end_symbol = "<|vision_end|>"
self.image_pad_symbol = "<|image_pad|>"
self.propri_symbol = "<|propri|>"
self.action_symbol = "<|action|>"
self.cam_names = self.config.cam_names
def _camera_name_mapping(self):
return self.config.train_config.get("data", {}).get("camera_name_mapping")
def _load_processor(self):
# Load tokenizer on model_device for inference
device = getattr(self.config, "model_device", "cuda")
processors_dict = load_wallx_processors(self.config.train_config, device=device)
self.processor = processors_dict["processor"]
self.action_mapper = processors_dict["action_mapper"]
self.tokenizer_mixin = processors_dict.get("tokenizer_mixin")
def _load_model(self):
from wall_x.trainer.adapters import resolve_adapter
model_type = self.config.train_config["model_type"]
adapter_cls = resolve_adapter(model_type)
ModelClass = adapter_cls.inference_model_class()
self.ModelClass = ModelClass
self.logger.info(f"initializing model: {model_type} ({ModelClass.__name__})")
self.model = ModelClass(
self.config.model_config,
self.processor,
self.tokenizer_mixin,
)
# log attention implementation - variant-specific layout dispatched
# via the adapter.
adapter_cls.log_attention_implementation(self.logger, self.model)
self.model_type = model_type
self.logger.info("resizing model token embeddings")
self.model.resize_token_embeddings(len(self.processor.tokenizer))
self.logger.info("token embedding resize done")
self.logger.info("casting selected params to bfloat16")
self.model.to_bfloat16_for_selected_params()
self.logger.info("bfloat16 cast done")
def load_ckpt(self, checkpoint_path: str = None):
if checkpoint_path is None:
checkpoint_path = self.config.checkpoint_path
if os.path.exists(os.path.join(checkpoint_path, "global_step.pth")):
global_step = torch.load(os.path.join(checkpoint_path, "global_step.pth"))[
"global_step"
]
self.logger.info(f"checkpoint global_step: {global_step}")
fsdp_ckpt = os.path.join(checkpoint_path, "pytorch_model_fsdp.bin")
safetensor_ckpt = os.path.join(checkpoint_path, "model.safetensors")
if os.path.exists(fsdp_ckpt):
self.logger.info(f"Loading FSDP checkpoint: {fsdp_ckpt}")
state_dict = torch.load(fsdp_ckpt, map_location="cpu")
# Unwrap outer dict (e.g. {'state_dict': {...}})
if isinstance(state_dict, dict) and "state_dict" in state_dict:
self.logger.info(
"Using nested state_dict inside pytorch_model_fsdp.bin"
)
state_dict = state_dict["state_dict"]
elif os.path.exists(safetensor_ckpt):
self.logger.info(f"loading safetensors checkpoint: {safetensor_ckpt}")
state_dict = load_file(safetensor_ckpt, device="cpu")
else:
raise FileNotFoundError(
f"ERROR: No checkpoint found under {checkpoint_path}. "
"Expecting either pytorch_model_fsdp.bin or model.safetensors."
)
# Qwen models need fused weight conversion
if not self.ModelClass.is_fused(state_dict):
self.logger.info(
"Converting non-fused weights to fused format...",
)
state_dict = self.ModelClass.convert_to_fused(state_dict)
else:
self.logger.info(
"The weights is fused, skipping conversion.",
)
state_dict = reshape_compatible_state_dict(
state_dict, self.model.state_dict(), log_fn=self.logger.info
)
msg = self.model.load_state_dict(state_dict, strict=False)
self.model.set_normalizer(
copy.deepcopy(self.normalizer_action),
copy.deepcopy(self.normalizer_propri),
)
self.logger.info(f"load_state_dict result: {msg}")
self.model.eval()
self.model.to(self.config.model_device)
self.model.to_bfloat16_for_selected_params()
if hasattr(self.model, "load_optimized_weights"):
self.model.load_optimized_weights(state_dict)
def _register_normalizers(self):
from wall_x._vendor.harrix.utils.normalizer import build_normalizers
self.normalizer_action, self.normalizer_propri, resolved = build_normalizers(
self.config.checkpoint_path,
self.config.train_config,
self.config.norm_key,
)
self.norm_key = resolved
self.config.norm_key = resolved
self._log_norm_debug(self.norm_key)
def _log_norm_debug(self, norm_key):
if not hasattr(self, "normalizer_action") or not hasattr(
self, "normalizer_propri"
):
self.logger.warning("[NormDebug] normalizer is not initialized yet")
return
if (
norm_key in self.normalizer_action.min
and norm_key in self.normalizer_propri.min
):
self.logger.debug(
"[NormDebug] norm_key=%s action(min/delta) shape=%s/%s",
norm_key,
tuple(self.normalizer_action.min[norm_key].shape),
tuple(self.normalizer_action.delta[norm_key].shape),
)
self.logger.debug(
"[NormDebug] action min(all)=%s delta(all)=%s",
self.normalizer_action.min[norm_key].detach().cpu().tolist(),
self.normalizer_action.delta[norm_key].detach().cpu().tolist(),
)
self.logger.debug(
"[NormDebug] propri(min/delta) shape=%s/%s",
tuple(self.normalizer_propri.min[norm_key].shape),
tuple(self.normalizer_propri.delta[norm_key].shape),
)
self.logger.debug(
"[NormDebug] propri min(all)=%s delta(all)=%s",
self.normalizer_propri.min[norm_key].detach().cpu().tolist(),
self.normalizer_propri.delta[norm_key].detach().cpu().tolist(),
)
else:
self.logger.warning(
"[NormDebug] norm_key=%s not found in normalizer", norm_key
)
@timer
def construct_model_input(
self, observation, prefix_text, postfix_text, pad_prefix=False
):
batch_size = len(observation)
dataset_names = [self.norm_key] * batch_size
self.logger.debug("[NormDebug] dataset_names=%s", dataset_names)
additional_inputs = {}
# -------- proprioception / masks (batch) --------
agent_pos_list = []
agent_pos_mask_list = []
dof_mask_list = []
for obs in observation:
if "robot_state_action_data" in obs:
robot_state_action_data = obs["robot_state_action_data"]
agent_pos = torch.from_numpy(robot_state_action_data.agent_pos)
# Normalize to [1, T, D] for batch cat
if agent_pos.dim() == 2:
agent_pos = agent_pos.unsqueeze(0)
agent_pos_list.append(agent_pos)
agent_pos_mask = torch.from_numpy(
robot_state_action_data.agent_pos_mask
)
if agent_pos_mask.dim() == 2:
agent_pos_mask = agent_pos_mask.unsqueeze(0)
agent_pos_mask_list.append(agent_pos_mask)
dof_mask = torch.from_numpy(robot_state_action_data.dof_mask)
if dof_mask.dim() == 1:
dof_mask = dof_mask.unsqueeze(0)
dof_mask_list.append(dof_mask)
# cat: [B, T, D] / [B, ...]
if len(agent_pos_list) > 0:
agent_pos = torch.cat(agent_pos_list, dim=0)
agent_pos_mask = torch.cat(agent_pos_mask_list, dim=0)
dof_mask = torch.cat(dof_mask_list, dim=0)
if self.normalizer_propri is not None:
agent_pos = self.normalizer_propri.normalize_data(
agent_pos, dataset_names
)
additional_inputs["proprioception"] = agent_pos.detach()
additional_inputs["agent_pos_mask"] = agent_pos_mask
additional_inputs["dof_mask"] = dof_mask
# -------- images (flattened, in placeholder scan order) --------
with ScopeTimer("resize_images"):
# TODO[KC]: optimize this using batch processing
image_inputs = []
all_image_sizes = []
if ENABLE_FAST_PREPROCESS:
for obs in observation:
current_image_inputs = self._resize_images_fast(obs)
image_inputs.extend(current_image_inputs)
# tensor shape is (H, W, C), convert to (W, H) to match PIL.size format
all_image_sizes.extend(
[
(image_i.shape[1], image_i.shape[0])
for image_i in current_image_inputs
]
)
else:
for obs in observation:
current_image_inputs = self._resize_images(obs)
image_inputs.extend(current_image_inputs)
# tensor shape is (H, W, C), convert to (W, H) to match PIL.size format
all_image_sizes.extend(
[
(image_i.shape[1], image_i.shape[0])
for image_i in current_image_inputs
]
)
additional_inputs["image_size"] = all_image_sizes
with ScopeTimer("preprocesser_call"):
inputs = preprocesser_call(
processor=self.model.processor,
prefix_text=prefix_text,
postfix_text=postfix_text,
images=image_inputs,
videos=None,
padding=True,
truncation=True,
return_tensors="pt",
max_length=resolve_max_length(self.config.train_config),
pad_to_128_multiple=False,
pad_prefix_to_same_length=pad_prefix,
norm_state=(
additional_inputs["proprioception"]
if "proprioception" in additional_inputs
and resolve_use_state_string_representation(
self.config.train_config
)
else None
),
agent_pos_mask=(
additional_inputs["agent_pos_mask"]
if "agent_pos_mask" in additional_inputs
else None
),
state_augmentation_prob=0.0,
state_drop_prob=0.0,
state_augmentation_ratio=0.0,
state_bins=resolve_state_bins(self.config.train_config),
inference_mode=True,
)
with ScopeTimer("convert_action_token_id and post "):
action_token_id = self.model.processor.tokenizer.convert_tokens_to_ids(
"<|action|>"
)
flow_action_mask = inputs["input_ids"] == action_token_id
additional_inputs["moe_token_types"] = flow_action_mask
additional_inputs["dataset_names"] = dataset_names
inputs.update(additional_inputs)
inputs = move_to_cuda(inputs, self.config.model_device)
return inputs
def get_text_for_dllm_action(self, instruction):
if (
self.config.train_config["data"].get("use_embodied_system_prompt_ratio", 0)
> 0
):
if self.norm_key != "x2_normal" and self.norm_key != "ex_normal":
self.config.robot_id = 0
cam_name_mapping = {cam_name: cam_name for cam_name in self.cam_names}
prologue = get_prologue_with_embodied_information(
dataset_name=self.norm_key,
cam_mapping=cam_name_mapping,
robot_id=self.config.robot_id,
uid="",
config=self.config.data_config,
)
else:
prologue = f"{self.role_start_symbol}system\nYou are a helpful assistant.{self.role_end_symbol}\n"
user_request = f"{self.role_start_symbol}user\nObservation:"
camera_name_mapping = self._camera_name_mapping()
for cam_name in self.cam_names:
user_request += (
f" {resolve_camera_label(cam_name, camera_name_mapping)}:"
f" {self.vision_start_symbol}{self.image_pad_symbol}{self.vision_end_symbol}"
)
user_request += "\nInstruction:"
text_prompt = f"\nPredict the next action in robot action.\nProprioception: {self.propri_symbol}\n"
user_message = (
f"{user_request} {instruction}{text_prompt}{self.role_end_symbol}\n"
)
placeholder_seq = self.tokenizer_mixin.get_placeholder_for_dllm()
ar_token = "".join(placeholder_seq) + "<|im_end|>\n"
assistant_message = f"{self.role_start_symbol}assistant\n{ar_token}"
flow_action = f"{self.action_symbol * self.config.action_horizon}"
prefix_text = prologue + user_message + assistant_message
postfix_text = flow_action
return prefix_text, postfix_text
@timer
def get_text_for_action(self, instruction):
if (
self.config.train_config["data"].get("use_embodied_system_prompt_ratio", 0)
> 0
):
if self.norm_key not in ["x2_normal", "ex_normal"]:
self.config.robot_id = 0
cam_name_mapping = {cam_name: cam_name for cam_name in self.cam_names}
prologue = get_prologue_with_embodied_information(
dataset_name=self.norm_key,
cam_mapping=cam_name_mapping,
robot_id=self.config.robot_id,
uid="",
config=self.config.data_config,
)
else:
prologue = f"{self.role_start_symbol}system\nYou are a helpful assistant.{self.role_end_symbol}\n"
user_request = f"{self.role_start_symbol}user\nObservation:"
camera_name_mapping = self._camera_name_mapping()
for cam_name in self.cam_names:
user_request += (
f" {resolve_camera_label(cam_name, camera_name_mapping)}:"
f" {self.vision_start_symbol}{self.image_pad_symbol}{self.vision_end_symbol}"
)
user_request += "\nInstruction:"
text_prompt = f"\nPredict the next action in robot action.\nProprioception: {self.propri_symbol}\n"
user_message = (
f"{user_request} {instruction}{text_prompt}{self.role_end_symbol}\n"
)
assistant_message = f"{self.role_start_symbol}assistant\n"
flow_action = f"{self.action_symbol * self.config.action_horizon}"
prefix_text = prologue + user_message + assistant_message
postfix_text = flow_action
return prefix_text, postfix_text
@timer
def get_text_for_subtask_generation(self, instruction):
prologue = f"{self.role_start_symbol}system\nYou are a helpful assistant.{self.role_end_symbol}\n"
user_request = f"{self.role_start_symbol}user\nObservation:"
camera_name_mapping = self._camera_name_mapping()
for cam_name in self.cam_names:
user_request += (
f" {resolve_camera_label(cam_name, camera_name_mapping)}:"
f" {self.vision_start_symbol}{self.image_pad_symbol}{self.vision_end_symbol}"
)
user_request += "\nInstruction:"
text_prompt = "\nPredict the next action in language.\n"
user_message = (
f"{user_request} {instruction}{text_prompt}{self.role_end_symbol}\n"
)
assistant_message = f"{self.role_start_symbol}assistant\n"
prefix_text = prologue + user_message + assistant_message
postfix_text = ""
return prefix_text, postfix_text
@timer
def _resize_images(self, observation):
image_inputs = []
for key in self.cam_names:
if key not in observation:
continue
current_obs = observation[key]
if isinstance(current_obs, np.ndarray):
img_pil = Image.fromarray(current_obs)
elif isinstance(current_obs, Image.Image):
img_pil = current_obs
else:
raise ValueError(f"Unsupported image type: {type(current_obs)}")
orig_width, orig_height = img_pil.size
target_size = self.config.data_config.resolution.get(key, -1)
if target_size != -1:
# Aspect-ratio-preserving resize
if orig_width > orig_height: # landscape
new_width = target_size
new_height = int(target_size * orig_height / orig_width)
else: # portrait
new_height = target_size
new_width = int(target_size * orig_width / orig_height)
img_pil = img_pil.resize((new_width, new_height))
# Apply smart resize (Qwen logic)
current_width, current_height = img_pil.size
resized_height, resized_width = smart_resize(
current_height,
current_width,
factor=self.config.data_config.image_factor,
min_pixels=self.config.data_config.min_pixels,
max_pixels=self.config.data_config.max_pixels,
)
resized_img = img_pil.resize((resized_width, resized_height))
resized_img = torch.from_numpy(np.array(resized_img)).to(
self.config.model_device
)
image_inputs.append(resized_img)
return image_inputs
def _resize_images_fast(self, observation):
import cv2
image_inputs = []
for key in self.cam_names:
if key not in observation:
continue
current_obs = observation[key]
orig_height, orig_width, _ = current_obs.shape
target_size = self.config.data_config.resolution.get(key, -1)
current_width, current_height = orig_width, orig_height
if target_size != -1:
# Aspect-ratio-preserving resize
if orig_width > orig_height: # landscape
new_width = target_size
new_height = int(target_size * orig_height / orig_width)
else: # portrait
new_height = target_size
new_width = int(target_size * orig_width / orig_height)
current_width = new_width
current_height = new_height
# Apply smart resize (Qwen logic)
resized_height, resized_width = smart_resize(
current_height,
current_width,
factor=self.config.data_config.image_factor, # FIXME
min_pixels=self.config.data_config.min_pixels, # FIXME
max_pixels=self.config.data_config.max_pixels, # FIXME
)
resized_img = cv2.resize(
current_obs,
(resized_width, resized_height),
interpolation=cv2.INTER_CUBIC,
)
resized_img = torch.from_numpy(resized_img).to(self.config.model_device)
image_inputs.append(resized_img)
return image_inputs
def infer_flow_action(self, observation, instruction):
self.logger.info("generating flow action")
self.logger.info(f"flow action instruction: {instruction}")
prefix_text, postfix_text = self.get_text_for_action(instruction)
model_input = self.construct_model_input(
[observation], [prefix_text], [postfix_text]
)
padding = (
torch.zeros_like(
self.normalizer_action.delta[model_input["dataset_names"][0]]
)
.unsqueeze(0)
.to("cpu")
)
padding_action = self.normalizer_action.normalize_data(
padding, model_input["dataset_names"]
).to(model_input["input_ids"].device)
self.logger.info(
"generate_flow_action start (horizon=%s, flow_steps=%s, device=%s)",
self.config.action_horizon,
self.config.num_inference_timesteps,
self.config.model_device,
)
if torch.cuda.is_available():
try:
free_b, total_b = torch.cuda.mem_get_info(
torch.device(self.config.model_device)
)
self.logger.info(
"CUDA mem before flow: free=%.2f GiB / total=%.2f GiB",
free_b / (1024**3),
total_b / (1024**3),
)
except Exception as e:
self.logger.warning("CUDA mem_get_info failed: %s", e)
with ScopeTimer("generate_flow_action"):
model_output = self.model.generate_flow_action(
action_horizon=self.config.action_horizon,
action_dim=self.config.action_dim,
num_inference_timesteps=self.config.num_inference_timesteps,
padding_action=padding_action,
**model_input,
)
self.logger.info("flow action generation done")
model_output["robot_state_action_data"] = observation["robot_state_action_data"]
model_output["robot_state_action_data"].save_action_data(
model_output["predict_action"]
)
self.logger.info("saved flow action to robot_state_action_data")
return model_output
def infer_flow_action_batch(self, observations, instructions):
"""
Batch flow action inference:
- observations: List[Dict], same format as single inference
- instructions: List[str], aligned with observations
Returns List[model_output] of length batch size
"""
assert len(observations) == len(
instructions
), "observations and instructions must have the same length"
batch_size = len(observations)
prefix_list = []
postfix_list = []
for ins in instructions:
prefix_text, postfix_text = self.get_text_for_action(ins)
prefix_list.append(prefix_text)
postfix_list.append(postfix_text)
# Build batch input in one preprocesser_call; avoid per-sample cat
batch_inputs = self.construct_model_input(
observations, prefix_list, postfix_list
)
padding_list = []
for ds_name in batch_inputs["dataset_names"]:
padding = (
torch.zeros_like(self.normalizer_action.delta[ds_name])
.unsqueeze(0)
.to("cpu")
)
padding_list.append(padding)
padding = torch.cat(padding_list, dim=0)
padding_action = self.normalizer_action.normalize_data(
padding, batch_inputs["dataset_names"]
).to(batch_inputs["input_ids"].device)
with ScopeTimer("generate_flow_action_batch"):
model_output = self.model.generate_flow_action(
action_horizon=self.config.action_horizon,
action_dim=self.config.action_dim,
num_inference_timesteps=self.config.num_inference_timesteps,
padding_action=padding_action,
**batch_inputs,
)
predict_action = model_output["predict_action"] # [B, H, D]
outputs = []
for i in range(batch_size):
single_action = predict_action[i : i + 1]
single_output = {
"predict_action": single_action,
"robot_state_action_data": observations[i]["robot_state_action_data"],
}
single_output["robot_state_action_data"].save_action_data(
single_output["predict_action"]
)
outputs.append(single_output)
return outputs
def infer_ar_action(self, observation, instruction):
self.logger.info("generating ar action")
self.logger.info(f"ar action instruction: {instruction}")
prefix_text, _ = self.get_text_for_action(instruction)
model_input = self.construct_model_input([observation], [prefix_text], [""])
model_output = self.model.generate_ar_action(
action_horizon=self.config.action_horizon,
action_dim=self.config.ar_action_dim,
num_inference_timesteps=self.config.num_inference_timesteps,
robot_type_id=self.robot_type_id,
**model_input,
)
self.logger.info("ar action generation done")
model_output["robot_state_action_data"] = observation["robot_state_action_data"]
model_output["robot_state_action_data"].save_action_data(
model_output["predict_action"]
)
self.logger.info("saved ar action to robot_state_action_data")
return model_output
def infer_subtask(self, observation, instruction):
self.logger.info("generating subtask")
self.logger.info(f"subtask instruction: {instruction}")
prefix_text, postfix_text = self.get_text_for_subtask_generation(instruction)
model_input = self.construct_model_input([observation], [prefix_text], [""])
model_output = self.model.generate_text(**model_input)
subtask = model_output["predict_output_text"][0].split("<|im_end|>")[0].strip()
self.logger.info(f"subtask generation done, subtask: {subtask}")
return subtask
def infer_vqa(self, observation, instruction):
self.logger.info("generating vqa answer")
if isinstance(observation["multi_modal"], list):
orig_size = observation["multi_modal"][0].size
else:
orig_size = observation["multi_modal"].size
prefix_text = instruction
model_input = self.construct_model_input([observation], [prefix_text], [""])
model_output = self.model.generate_text(**model_input)
answer = model_output["predict_output_text"][0].split("<|im_end|>")[0].strip()
self.logger.info(f"vqa answer done, answer: {answer}")
answer = reverse_grounding_points(
answer,
orig_size[1],
orig_size[0],
model_input["image_size"][0][1],
model_input["image_size"][0][0],
self.config.data_config.model_type,
)
points = extract_grounding_points(answer)
return {
"answer": answer,
"points": points,
}
def infer_dllm_action(
self, observation, instruction, use_ar_action=False, dataset_name="x2_normal"
):
assert self.model_type in ["qwen2_5"], "DLLM only supports qwen2_5"
prefix_text, postfix_text = self.get_text_for_dllm_action(instruction)
model_input = self.construct_model_input(
[observation], [prefix_text], [postfix_text], pad_prefix=True
)
model_input = self.model.update_infer_dllm_position_mask(model_input)
total_ar_step = self.tokenizer_mixin.inference_ar_steps_for_dllm
cnt = 0
while cnt < 3: # fast mode: at most 3 retries
model_output = self.model.generate_dllm_action(
action_horizon=self.config.action_horizon,
action_dim=self.config.action_dim,
ar_action_dim=self.config.ar_action_dim,
num_inference_timesteps=self.config.num_inference_timesteps,
use_ar_action=use_ar_action,
total_ar_step=total_ar_step,
robot_type_id=self.robot_type_id, # for v3.1 delta decode
**model_input,
)
if model_output["predict_action"] is not None:
break
cnt += 1
self.logger.warning(f"dllm action generation failed, retry {cnt}")
self.logger.info("dllm action generation done")
model_output["robot_state_action_data"] = observation["robot_state_action_data"]
model_output["robot_state_action_data"].save_action_data(
model_output["predict_action"]
)
self.logger.info("saved dllm action to robot_state_action_data")
return model_output
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,423 @@
import socket
import struct
import json
import cv2
import numpy as np
import time
import select
import errno
import os
import pickle
import threading
from wall_x._vendor.harrix.serving._wallx_infer.logger import InferLogger
_DEFAULT_ROBOT_CONFIG = {
0: {"host": "127.0.0.1", "action_port": 57749, "keyboard_port": 58849},
1: {"host": "127.0.0.1", "action_port": 57750, "keyboard_port": 58850},
2: {"host": "127.0.0.1", "action_port": 57751, "keyboard_port": 58851},
3: {"host": "127.0.0.1", "action_port": 57761, "keyboard_port": 58861},
}
class RobotRegistry:
def __init__(self):
self.registry = _DEFAULT_ROBOT_CONFIG
self.logger = InferLogger.get_controller_logger("RobotRegistry")
self.logger.debug(f"init robot registry, registered {len(self.registry)} robots")
def register_robot(self, robot_id, host, action_port, keyboard_port):
if robot_id in self.registry:
self.logger.warning(f"robot ID {robot_id} already registered")
return False
else:
self.registry[robot_id] = {
"host": host,
"action_port": action_port,
"keyboard_port": keyboard_port,
}
self.logger.info(
f"register robot ID={robot_id}: host={host}, action_port={action_port}, keyboard_port={keyboard_port}"
)
return True
def get_robot_info(self, robot_id):
if robot_id in self.registry:
info = self.registry[robot_id]
self.logger.debug(f"get robot ID={robot_id} info: {info}")
return info
else:
self.logger.error(f"robot ID={robot_id} not found")
return None
def exist(self, robot_id):
return robot_id in self.registry
class RobotCommunication:
def __init__(self, robot_id, host=None, action_port=None, keyboard_port=None):
self.logger = InferLogger.get_controller_logger("RobotCommunication")
self.logger.debug(f"init robot comms: robot_id={robot_id}")
self.robot_register = RobotRegistry()
if self.robot_register.exist(robot_id):
self.robot_info = self.robot_register.get_robot_info(robot_id)
self.logger.debug(f"using registered robot config: {robot_id}")
else:
self.robot_register.register_robot(
robot_id, host, action_port, keyboard_port
)
self.robot_info = self.robot_register.get_robot_info(robot_id)
self.logger.debug(f"register new robot config: {robot_id}")
self.action_sock = None
self.action_conn = None
self.keyboard_sock = None
self.keyboard_conn = None
self.client_socks = []
def connect(self):
self.logger.info("establishing socket connection...")
self.action_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.action_sock.setblocking(True)
self.action_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
host = self.robot_info["host"]
port = self.robot_info["action_port"]
self.action_sock.bind((host, port))
self.action_sock.listen(1)
self.logger.info(f"listening on action port: {host}:{self.action_sock.getsockname()[1]}")
action_thread = threading.Thread(target=self.handle_action_client)
self.logger.debug("starting connection handler thread...")
action_thread.start()
action_thread.join()
self.logger.info("socket connection established")
def handle_action_client(self):
self.logger.debug("waiting for client connection...")
self.action_conn, addr = self.action_sock.accept()
self.logger.info(f"accepted connection from {addr}")
def recv_image(self, index):
self.logger.debug(f"receiving image index={index}...")
image_size = struct.unpack("<L", self.action_conn.recv(4))[0]
self.logger.debug(f"image size: {image_size} bytes")
image = self.recvall(self.action_conn, image_size)
nparr = np.frombuffer(image, np.uint8)
image = cv2.imdecode(nparr, cv2.IMREAD_COLOR)
image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
self.logger.debug(f"image decoded: shape={image.shape}")
return image
def recv_action_data(self):
self.logger.debug("receiving action data...")
data_size = struct.unpack("<L", self.action_conn.recv(4))[0]
self.logger.debug(f"action data size: {data_size} bytes")
data = self.recvall(self.action_conn, data_size)
action_data = json.loads(data.decode("utf8"))
self.logger.debug(f"action data received: {len(action_data)} fields")
return action_data
def accept_connections(self):
try:
client_sock, addr = self.keyboard_sock.accept()
client_sock.setblocking(0) # Set non-blocking mode for the client socket
self.client_socks.append(client_sock)
except BlockingIOError:
# The socket is in non-blocking mode and there are no pending connections
pass
def recv_keyboard_input(self):
for client_sock in list(self.client_socks): # Iterate over a copy of the list
if client_sock.fileno() == -1:
# Socket has been closed, remove it from the list
self.client_socks.remove(client_sock)
continue
read_sockets, _, _ = select.select([client_sock], [], [], 0)
if client_sock in read_sockets:
try:
data_size_bytes = client_sock.recv(4)
if not data_size_bytes:
self.logger.debug("client closed connection")
client_sock.close()
self.client_socks.remove(client_sock)
continue
data_size = struct.unpack("<L", data_size_bytes)[0]
data = RobotCommunication.recvall(client_sock, data_size)
if not data:
self.logger.debug("client closed connection")
client_sock.close()
self.client_socks.remove(client_sock)
continue
json_data = json.loads(data.decode("utf8"))
self.logger.debug(f"keyboard input: {json_data}")
return json_data
except socket.error as e:
if e.errno == errno.EAGAIN or e.errno == errno.EWOULDBLOCK:
# Resource temporarily unavailable, ignore the error
pass
else:
self.logger.error(f"socket error: {e}")
client_sock.close()
self.client_socks.remove(client_sock)
continue
time.sleep(0.1)
return None
def send_dict(self, dict_data):
self.logger.debug(f"sending dict: {len(dict_data)} fields")
data_str = json.dumps(dict_data)
data_bytes = data_str.encode("utf-8")
self.action_conn.sendall(struct.pack("<L", len(data_bytes)))
self.action_conn.sendall(data_bytes)
self.logger.debug(f"data sent: {len(data_bytes)} bytes")
def close(self):
self.logger.info("closing socket connection...")
self.action_sock.close()
for sock in self.client_socks:
sock.close()
self.logger.info(f"closed {len(self.client_socks)} client connections")
# self.keyboard_sock.close()
@staticmethod
def recvall(sock, count):
buf = b""
while count:
newbuf = sock.recv(count)
if not newbuf:
return None
buf += newbuf
count -= len(newbuf)
return buf
class DummyRobotCommunication:
def __init__(self):
pass
def send_dict(self, dict_data):
pass
class DummyRobotController:
def __init__(
self,
robot_id: int,
host: str = None,
port: str = None,
views_path: str | None = None,
state_path: str | None = None,
):
self.robot_id = robot_id
self.host = host
self.port = port
self.views_path = views_path or os.environ.get("WALL_X_DUMMY_VIEWS_PATH")
self.state_path = state_path or os.environ.get("WALL_X_DUMMY_STATE_PATH")
self.robot_comm = DummyRobotCommunication()
self.logger = InferLogger.get_controller_logger("DummyRobotController")
self.logger.info(
f"init virtual robot controller (debug): robot_id={robot_id}"
)
def connect(self):
self.logger.debug("virtual controller connect (no-op)")
pass
def close(self):
self.logger.debug("virtual controller close (no-op)")
pass
def recv_image(
self, cam_names: list = ["camera_left", "camera_front", "camera_right"]
) -> dict:
if not self.views_path:
raise RuntimeError(
"DummyRobotController requires views_path or "
"WALL_X_DUMMY_VIEWS_PATH."
)
self.logger.debug(f"load views from pickle: {cam_names}")
with open(self.views_path, "rb") as f:
all_views = pickle.load(f)
self.logger.debug(f"views loaded: {len(all_views)} views")
return all_views
def recv_action(self):
if not self.state_path:
raise RuntimeError(
"DummyRobotController requires state_path or "
"WALL_X_DUMMY_STATE_PATH."
)
self.logger.debug("load actions from pickle")
with open(self.state_path, "rb") as f:
all_actions = pickle.load(f)
self.logger.debug(f"actions loaded: {len(all_actions)} fields")
return all_actions
class RobotController:
def __init__(
self,
robot_id: int,
host: str = None,
port: str = None,
max_time_step: int = 10000,
):
self.logger = InferLogger.get_controller_logger("RobotController")
self.logger.info(
f"init robot controller: robot_id={robot_id}, max_time_step={max_time_step}"
)
self.robot_comm = RobotCommunication(robot_id, host, port)
self.max_time_step = max_time_step
self.global_step = 0
def connect(self):
self.logger.info("connecting robot controller...")
self.robot_comm.connect()
self.logger.info("robot controller connected")
def close(self):
self.logger.info("closing robot controller...")
self.robot_comm.close()
self.logger.info("robot controller closed")
def prediction(self, views, actions) -> dict:
raise NotImplementedError
def recv_image(
self, cam_names: list = ["camera_left", "camera_front", "camera_right"]
) -> dict:
self.logger.debug(f"receive images: {cam_names}")
views = {}
for i, name in enumerate(cam_names):
image = np.array(self.robot_comm.recv_image(i))
views[name] = image[None, :]
self.logger.debug(f"received {name}: shape={image.shape}")
self.logger.debug(f"all images received: {len(views)} views")
return views
def recv_action(self):
self.logger.debug("receiving action data...")
action_data = self.robot_comm.recv_action_data()
return action_data
def recv_keyboard_input(self):
self.robot_comm.accept_connections()
json_data = self.robot_comm.recv_keyboard_input()
if json_data is not None:
self.logger.debug(
f"keyboard: motionStatus={json_data.get('motionStatus')}, armMode={json_data.get('armMode')}"
)
return json_data["motionStatus"], json_data["armMode"]
return None, None
def reset(self):
self.logger.info(f"reset controller: global_step {self.global_step} -> 0")
self.global_step = 0
def record_start(self):
self.logger.info("start recording...")
record_signal = {"cmd": "RECORD_START"}
self.robot_comm.send_dict(record_signal)
def record_continue(self):
self.logger.debug("continue recording...")
record_signal = {"cmd": "RECORD_CONTINUE"}
self.robot_comm.send_dict(record_signal)
def record_start_process(self):
if self.global_step == 0:
self.logger.info("waiting for START signal...")
action = None
while action != "START":
action, _ = self.recv_keyboard_input()
self.logger.info("received START signal")
self.record_start()
self.record_continue()
def set_zero(self):
self.logger.info("setting zero pose...")
record_signal = {"cmd": "INIT_ZERO", "gripper": [0.0, 0.0]}
self.robot_comm.send_dict(record_signal)
self.reset()
self.logger.info("zero pose set")
def record_stop(self):
self.logger.info("stop recording...")
record_signal = {"cmd": "RECORD_STOP"}
self.robot_comm.send_dict(record_signal)
time.sleep(0.01)
def recover_from_failure(self):
self.logger.warning("recovering from failure...")
record_signal = {"cmd": "TO_MASTER_SLAVE"}
self.robot_comm.send_dict(record_signal)
time.sleep(0.01)
self.logger.info("waiting for START signal...")
action = None
while action != "START":
action, _ = self.recv_keyboard_input()
self.logger.info("recovery done, resume recording")
self.record_start()
def reset_to_zero(self):
self.logger.info("reset to zero pose...")
self.record_stop()
self.set_zero()
def to_slave(self):
self.logger.info("switching to slave mode...")
record_signal = {"cmd": "TO_SLAVE"}
self.robot_comm.send_dict(record_signal)
time.sleep(0.01)
def run(
self,
record_mode=False,
cam_names: list = ["camera_left", "camera_front", "camera_right"],
):
while self.global_step <= self.max_time_step:
if record_mode:
self.record_start_process()
self.global_step += 1
action = self.recv_action()
view = self.recv_image(cam_names)
pred = self.prediction(view, action)
self.robot_comm.send_dict(pred)
if record_mode:
action, arm_mode = self.recv_keyboard_input()
if action is not None and arm_mode is not None:
self.logger.info("action: %s, arm_mode: %s", action, arm_mode)
if action == "STOP" and arm_mode == "ARM_TEST_MODE_MS":
self.record_stop()
self.recover_from_failure()
action, arm_mode = None, None
while action != "STOP" or arm_mode != "ARM_TEST_MODE_S":
action, arm_mode = self.recv_keyboard_input()
self.record_stop()
self.to_slave()
self.reset()
elif arm_mode == "ARM_TEST_MODE_S" and action == "INIT":
self.reset_to_zero()
@@ -0,0 +1,280 @@
import numpy as np
from scipy.signal import savgol_filter
from scipy.spatial.transform import Rotation as R # TODO: convert to numba
from collections import deque
import threading
from wall_x._vendor.harrix.serving._wallx_infer.logger import InferLogger
class KeyboardThread(threading.Thread):
"""
Simple keyboard listener thread with stop and reset
"""
def __init__(self):
self.should_reset = False
self.should_stop = False
self.new_instruction_index = None # Stores new instruction index
self.logger = InferLogger.get_utils_logger("KeyboardThread")
super(KeyboardThread, self).__init__(name="keyboard-thread", daemon=True)
self.show_help()
self.start()
def run(self):
"""Listen for keyboard input"""
while True:
try:
user_input = input().strip().lower()
if user_input in ["s", "stop"]:
self.should_stop = not self.should_stop
self.logger.info("[keyboard] stop signal sent")
elif user_input in ["r", "reset"]:
self.logger.info("[keyboard] resetting...")
self.should_reset = True
self.logger.info("[keyboard] reset signal sent")
elif user_input.isdigit():
# Numeric input: switch instruction index
index = int(user_input)
self.new_instruction_index = index
self.logger.info(f"[keyboard] switched to instruction index: {index}")
else:
self.logger.info(f"[keyboard] input received: {user_input}. no action taken.")
except EOFError:
break
except Exception as e:
self.logger.error(f"[keyboard] error: {e}")
def show_help(self):
self.logger.info(
"[keyboard] controls: 's' stop, 'r' reset, digit switches instruction index"
)
# Arm trajectory parameters
ARM_MAX_VELOCITY = 0.02
ARM_EXECUTION_HZ = 20
ARM_MIN_EXECUTION_TIME = 5.0
ARM_MAX_EXECUTION_TIME = 15.0
class UnifiedTrajectoryProcessor:
"""Unified trajectory processor"""
@staticmethod
def interpolate_trajectory_batch(trajectories, target_length, smooth=True):
"""
Interpolate multiple trajectories to a common length
Args:
trajectories: list of np.array, each shape (N, D)
target_length: int, target length
smooth: bool, whether to smooth
Returns:
list of np.array, interpolated trajectories
"""
if not trajectories:
return []
results = []
for traj in trajectories:
if len(traj) == 0:
results.append(np.zeros((target_length, traj.shape[1])))
continue
if len(traj) == target_length:
results.append(traj)
continue
# Vectorized interpolation
original_indices = np.linspace(0, len(traj) - 1, len(traj))
target_indices = np.linspace(0, len(traj) - 1, target_length)
# Handle different data types
if traj.shape[1] == 7: # Arm data [x,y,z,rx,ry,rz,gripper]
interpolated = UnifiedTrajectoryProcessor._interpolate_arm_trajectory(
traj, original_indices, target_indices, target_length
)
else: # Other data (height, current, etc.)
interpolated = np.zeros((target_length, traj.shape[1]))
for i in range(traj.shape[1]):
interpolated[:, i] = np.interp(
target_indices, original_indices, traj[:, i]
)
# Smoothing
if smooth and len(interpolated) >= 5:
interpolated = UnifiedTrajectoryProcessor._smooth_trajectory(
interpolated
)
results.append(interpolated)
return results
@staticmethod
def _interpolate_arm_trajectory(
traj, original_indices, target_indices, target_length
):
"""Optimized arm trajectory interpolation"""
interpolated = np.zeros((target_length, 7))
# Vectorized interp for position and gripper
for i in [0, 1, 2, 6]: # x, y, z, gripper
interpolated[:, i] = np.interp(target_indices, original_indices, traj[:, i])
# Quaternion interpolation (vectorized)
quaternions = R.from_euler("xyz", traj[:, 3:6]).as_quat()
interpolated_quats = np.zeros((target_length, 4))
for i in range(4):
interpolated_quats[:, i] = np.interp(
target_indices, original_indices, quaternions[:, i]
)
# Batch normalize
norms = np.linalg.norm(interpolated_quats, axis=1, keepdims=True)
interpolated_quats = interpolated_quats / norms
# Batch convert back to euler
interpolated[:, 3:6] = R.from_quat(interpolated_quats).as_euler("xyz")
return interpolated
@staticmethod
def _interpolate_position_trajectory(
traj, original_indices, target_indices, target_length
):
"""Optimized position trajectory interpolation"""
interpolated = np.zeros((target_length, 3))
for i in range(3):
interpolated[:, i] = np.interp(target_indices, original_indices, traj[:, i])
return interpolated
@staticmethod
def _smooth_trajectory(trajectory):
"""Vectorized smoothing"""
if len(trajectory) < 5:
return trajectory
try:
# Smooth all dimensions in batch
smoothed = np.zeros_like(trajectory)
for dim in range(trajectory.shape[1]):
smoothed[:, dim] = savgol_filter(
trajectory[:, dim],
min(
5,
(
len(trajectory)
if len(trajectory) % 2 == 1
else len(trajectory) - 1
),
),
3,
mode="nearest",
)
return smoothed
except Exception:
return trajectory
@staticmethod
def calculate_optimal_trajectory_length(left_traj, right_traj):
"""Compute optimal trajectory length"""
# Vectorized distance computation
def calc_distance(traj):
if len(traj) < 2:
return 0.0
pos_diff = traj[1:, :3] - traj[:-1, :3]
return np.sum(np.linalg.norm(pos_diff, axis=1))
distances = [calc_distance(left_traj), calc_distance(right_traj)]
max_distance = max(distances)
if max_distance > 1e-6:
execution_time = np.clip(
max_distance / ARM_MAX_VELOCITY,
ARM_MIN_EXECUTION_TIME,
ARM_MAX_EXECUTION_TIME,
)
else:
execution_time = ARM_MIN_EXECUTION_TIME
return max(int(execution_time * ARM_EXECUTION_HZ), len(left_traj))
class VehiclePoseHandler:
"""Vehicle pose and velocity computation"""
def __init__(self):
self.current_pose = None
self.previous_pose = None
self.pose_history = deque(maxlen=10)
self.logger = InferLogger.get_utils_logger("VehiclePoseHandler")
def update_pose(self, new_pose):
"""Update vehicle pose"""
if new_pose is not None:
self.previous_pose = self.current_pose
self.current_pose = np.array(new_pose)
self.pose_history.append(self.current_pose.copy())
self.logger.info("current_pose %s", self.current_pose)
return self.current_pose
def velocity_to_pose(self, vx_body, vy_body, vyaw, dt, start_pose=None):
"""Convert body-frame velocity to global pose"""
if start_pose is None:
if self.current_pose is not None:
start_pose = self.current_pose.copy()
else:
start_pose = np.array([0.0, 0.0, 0.0])
x, y, theta = start_pose
# Body velocity to global displacement
cos_theta = np.cos(theta)
sin_theta = np.sin(theta)
# Transform: body frame -> global frame
dx_global = (vx_body * cos_theta - vy_body * sin_theta) * dt
dy_global = (vx_body * sin_theta + vy_body * cos_theta) * dt
dtheta = vyaw * dt
# Compute new pose
x_new = x + dx_global
y_new = y + dy_global
theta_new = theta + dtheta
# Wrap angle to [-pi, pi]
theta_new = (theta_new + np.pi) % (2 * np.pi) - np.pi
return np.array([x_new, y_new, theta_new])
def compute_body_velocities_from_poses(
self, current_pose, previous_pose, dt=1 / 20
):
"""Compute body-frame velocity from pose delta"""
if current_pose is None or previous_pose is None:
return np.array([0.0, 0.0, 0.0])
# Global-frame displacement
dx_global = current_pose[0] - previous_pose[0]
dy_global = current_pose[1] - previous_pose[1]
dtheta = current_pose[2] - previous_pose[2]
# Use previous frame angle for transform
theta = previous_pose[2]
cos_theta = np.cos(theta)
sin_theta = np.sin(theta)
# Global displacement to body velocity
vx_body = (dx_global * cos_theta + dy_global * sin_theta) / dt
vy_body = (-dx_global * sin_theta + dy_global * cos_theta) / dt
vyaw = dtheta / dt
return np.array([vx_body, vy_body, vyaw])
@@ -0,0 +1,280 @@
#!/usr/bin/env python3
"""
Server script for Wall-X model.
This script serves a Wall-X model using a websocket server, allowing
clients to connect and get action predictions from observations.
"""
import dataclasses
import enum
import inspect
import logging
import os
import socket
import sys
import yaml
import traceback
import tyro
from wall_x._vendor.harrix.serving.websocket_policy_server import WebsocketPolicyServer
def _server_model_config_to_infer_kwargs(model_config) -> dict:
from wall_x._vendor.harrix.serving._wallx_infer.infer_config import InferConfig
infer_params = inspect.signature(InferConfig.__init__).parameters
return {
k: v
for k, v in vars(model_config).items()
if v is not None and k in infer_params
}
def get_wallx_policy(model_config, image_passing_mode, serialize_actions=True):
from wall_x._vendor.harrix.serving.policy.wall_x_policy import WallXPolicy
from wall_x._vendor.harrix.serving._wallx_infer.infer_config import InferConfig
config = InferConfig(**_server_model_config_to_infer_kwargs(model_config))
return WallXPolicy(config=config, image_passing_mode=image_passing_mode, serialize_actions=serialize_actions)
logger = logging.getLogger(__name__)
class EnvMode(enum.Enum):
"""Supported environments/datasets."""
X2ROBOT = "x2robot"
LIBERO = "libero"
@dataclasses.dataclass
class ServerModelConfig:
"""Configuration for loading a Wall-X model."""
checkpoint_path: str | None = None
train_config_path: str | None = None
# robot_host: str = '0.0.0.0'
# robot_port: int = 33723
robot_type: str = "desktop" # ["desktop", "turtle"]
robot_action_start_ratio: float = (
0.0 # proportion of action execution to start from
)
robot_action_end_ratio: float = 1.0 # proportion of action execution to end at
robot_action_interpolate_multiplier: int = 10 # action interpolation multiplier
robot_use_joint_angle_control: bool = (
False # use joint angle control (model must predict joints)
)
turtle_as_desktop: bool = (
False # use turtle platform as desktop with fixed base/head/camera/height
)
action_horizon: int = 32 # specify the correct horizon for the model
action_dim: int | None = None
model_device: str = "cuda"
num_inference_timesteps: int = 10
num_inference_steps: int | None = None
cfg_scale: float | None = None
seed: int | None = None
save_video_dir: str = "./videos"
# Please specify explicitly if the checkpoint was not trained on the x2robot dataset.
norm_key: str | None = None
# Model cameras; None = infer from train config ``data.key_mappings.camera``.
cam_names: list[str] | None = None
# Robot camera keys in incoming websocket observations.
camera_front_key: str = "camera_front"
camera_left_key: str = "camera_left"
camera_right_key: str = "camera_right"
# Serving prompt controls. If the client request has no instruction,
# default_instruction is used. prompt_template follows train-config semantics.
default_instruction: str | None = None
prompt_template: str | None = None
qwen25_prompt_template: str | None = None
prompt_priority_order: str | None = None
@dataclasses.dataclass
class Args:
"""Arguments for the serve_wall_x script."""
# Environment mode (used for default configurations)
env: EnvMode = EnvMode.X2ROBOT
# Model configuration. If not provided, uses default config for the environment
model_config: ServerModelConfig | None = None
# Default text prompt to use if not provided in observation
default_prompt: str | None = None
# Port to serve the policy on
port: int = 43007
# Host to bind the server to
host: str = "0.0.0.0"
# Enable debug logging
debug: bool = False
# Image passing mode
image_passing_mode: str = "base64" # ["numpy", "base64"]
# Model type
model_type: str = "wallx" # OSS supports qwen2.5 Wall-X only
# Serialize actions via robot_preprocessor (True for robot control, False for raw output)
serialize_actions: bool = True
# -- Dynamic batching -----------------------------------------
# Set max_batch_size to enable dynamic batching. None = single mode.
max_batch_size: int | None = None
max_wait_time_ms: float = 0
max_queue_size: int = 100
timeout_ms: float = 30000
# -- Engine flags ---------------------------------------------
enable_experimental_engine: bool = False
enable_cuda_graph: bool = False
# Default model configurations for each environment
DEFAULT_CONFIGS: dict[EnvMode, ServerModelConfig] = {
EnvMode.X2ROBOT: ServerModelConfig(
checkpoint_path=None,
train_config_path=None,
robot_action_start_ratio=0.0,
robot_action_end_ratio=1.0,
robot_action_interpolate_multiplier=10,
robot_use_joint_angle_control=False,
turtle_as_desktop=False,
action_horizon=32,
action_dim=None,
model_device="cuda",
num_inference_timesteps=10,
),
EnvMode.LIBERO: ServerModelConfig(
checkpoint_path=None,
train_config_path=None,
robot_type="desktop",
robot_action_start_ratio=0.0,
robot_action_end_ratio=1.0,
robot_action_interpolate_multiplier=1,
action_horizon=10,
action_dim=None,
model_device="cuda",
num_inference_timesteps=10,
cam_names=None,
),
}
def get_model_config(args: Args) -> ServerModelConfig:
"""Get model configuration from args or defaults."""
if args.model_config is not None:
return args.model_config
if config := DEFAULT_CONFIGS.get(args.env):
logger.info(f"Using default configuration for {args.env.value}")
return config
raise ValueError(
f"No default configuration for {args.env.value}. "
f"Please provide --model-config with model_path and action_tokenizer_path."
)
def create_policy(args: Args):
"""Create a policy from the given arguments."""
model_config = get_model_config(args)
if args.model_type != "wallx":
raise ValueError(
f"Unsupported model type: {args.model_type!r}. "
"The public package only supports model_type='wallx'."
)
policy = get_wallx_policy(
model_config, args.image_passing_mode, args.serialize_actions
)
return policy
def main(args: Args) -> None:
"""Main function to start the Wall-X model server."""
log_level = logging.DEBUG if args.debug else logging.INFO
logging.basicConfig(
level=log_level,
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
)
# Set engine environment variables
if args.enable_experimental_engine:
os.environ["ENABLE_EXPERIMENTAL_INFERENCE_ENGINE"] = "true"
logger.info("ENABLE_EXPERIMENTAL_INFERENCE_ENGINE=true")
if args.enable_cuda_graph:
os.environ["ENABLE_CUDA_GRAPH"] = "true"
logger.info("ENABLE_CUDA_GRAPH=true")
logger.info("Starting model server")
logger.info(f"Model type: {args.model_type}")
logger.info(f"Environment: {args.env.value}")
logger.info(f"Port: {args.port}")
logger.info(f"Host: {args.host}")
logger.info(f"Serialize actions: {args.serialize_actions}")
# Create policy
try:
policy = create_policy(args)
except Exception as e:
logger.error(f"Failed to create policy: {e}")
logger.error(traceback.format_exc())
sys.exit(1)
# Get policy metadata
policy_metadata = policy.metadata
policy_metadata["env"] = args.env.value
# Get network info
hostname = socket.gethostname()
try:
local_ip = socket.gethostbyname(hostname)
except Exception:
local_ip = "unknown"
logger.info(f"Server hostname: {hostname}")
logger.info(f"Server IP: {local_ip}")
logger.info(f"Server will be available at: ws://{args.host}:{args.port}")
logger.info(f"Health check endpoint: http://{args.host}:{args.port}/healthz")
batching_str = (
f"batch_size={args.max_batch_size}, wait={args.max_wait_time_ms}ms"
if args.max_batch_size
else "disabled"
)
logger.info(f"Batching: {batching_str}")
# Create and start server
server = WebsocketPolicyServer(
policy=policy,
host=args.host,
port=args.port,
metadata=policy_metadata,
max_batch_size=args.max_batch_size,
max_wait_time_ms=args.max_wait_time_ms,
max_queue_size=args.max_queue_size,
timeout_ms=args.timeout_ms,
)
logger.info("Starting server...")
try:
server.serve_forever()
except KeyboardInterrupt:
logger.info("Server stopped by user")
except Exception as e:
logger.error(f"Server error: {e}")
sys.exit(1)
if __name__ == "__main__":
main(tyro.cli(Args))
@@ -0,0 +1,5 @@
"""Wall-X policy for harrix websocket serving."""
from .wall_x_policy import WallXPolicy
__all__ = ["WallXPolicy"]
@@ -0,0 +1,104 @@
"""Test-time Laplacian smoothing for serving model outputs.
Mirrors the open-loop implementation in
`wallx_bus2604/wall-x/run_scripts/infer_openloop.py`
(_laplacian_smooth @ 203-209, smooth_action / smooth_gripper fields @ 186-187,
application @ 458-463). Invoked by WallXPolicy before downstream 6D->euler
conversion so that smoothed rotations propagate through serialization.
"""
from __future__ import annotations
from typing import List, Optional, Sequence
import numpy as np
import torch
from wall_x._vendor.harrix.serving._wallx_infer.base_dataclass import dof_dims
def _laplacian_smooth(a: np.ndarray, lam: float = 1.0, iters: int = 30) -> np.ndarray:
"""Iterative Laplacian smoothing along axis 0; endpoints pinned."""
a = a.copy()
orig = a.copy()
for _ in range(iters):
a[1:-1] = (orig[1:-1] + lam * (a[:-2] + a[2:])) / (1 + 2 * lam)
return a
def _gripper_column_indices(
predict_action_keys: Sequence[str],
action_padding_dof: Optional[int] = None,
) -> List[int]:
"""Column indices in the flat (T, D) layout that correspond to gripper dofs."""
cols: List[int] = []
dof_start = 0
for key in predict_action_keys:
if key == "action_padding":
dof_start += action_padding_dof or 0
continue
short = key.replace("follow_", "").replace("master_", "")
width = dof_dims[short]
if "gripper" in short:
cols.extend(range(dof_start, dof_start + width))
dof_start += width
return cols
_LAZY_ACTION_KEYS = (
"action_left_ee_cartesian_pos",
"action_right_ee_cartesian_pos",
"action_left_ee_rotation",
"action_right_ee_rotation",
"action_left_ee_rotation_6D",
"action_right_ee_rotation_6D",
)
def apply_smoothing(
model_output: dict,
smooth_action: bool,
smooth_gripper: bool,
predict_action_keys: Sequence[str],
action_padding_dof: Optional[int] = None,
action_dim: Optional[int] = None,
) -> None:
"""Smooth `model_output['predict_action']` in place and refresh per-arm keys."""
if not smooth_action:
return
pa = model_output.get("predict_action")
if pa is None:
return
was_tensor = isinstance(pa, torch.Tensor)
arr = pa.detach().cpu().numpy() if was_tensor else np.asarray(pa)
orig_ndim = arr.ndim
if orig_ndim == 3:
if arr.shape[0] != 1:
return
arr = arr[0]
if arr.ndim != 2 or arr.shape[0] < 3:
return
if action_dim is not None and arr.shape[-1] != action_dim:
return
orig = arr.copy()
smoothed = _laplacian_smooth(arr)
if not smooth_gripper:
for c in _gripper_column_indices(predict_action_keys, action_padding_dof):
if c < smoothed.shape[-1]:
smoothed[:, c] = orig[:, c]
out = smoothed[None] if orig_ndim == 3 else smoothed
if was_tensor:
out = torch.from_numpy(out).to(device=pa.device, dtype=pa.dtype)
model_output["predict_action"] = out
rsd = model_output.get("robot_state_action_data")
if rsd is not None:
for k in _LAZY_ACTION_KEYS:
if k in rsd.data:
rsd.data[k] = None
rsd.save_action_data(out)
@@ -0,0 +1,328 @@
import base64
import logging
from typing import Dict, Any, List
import cv2
import numpy as np
import torch
from wall_x._vendor.harrix.serving.websocket_policy_server import BasePolicy
from wall_x._vendor.harrix.serving._wallx_infer.infer_config import InferConfig
from wall_x._vendor.harrix.serving._wallx_infer.model_wrapper import WallxModelWrapper
from wall_x._vendor.harrix.serving._wallx_infer.robot import (
DesktopRobotPreprocessor,
TurtleRobotPreprocessor,
EX001RobotPreprocessor,
)
from wall_x._vendor.harrix.serving.policy._smoothing import apply_smoothing
from wall_x.utils.timers import ScopeTimer
logger = logging.getLogger(__name__)
# Inference modes aligned with model_wrapper for vqa / batch extensions
INFER_MODE_FLOW = "flow"
INFER_MODE_AR = "ar"
INFER_MODE_FLOW_WITH_SUBTASK = "flow_with_subtask"
INFER_MODE_DLLM_FLOW = "dllm"
INFER_MODE_DLLM_DD = "discrete_diffusion"
ACTION_INFER_MODES = (
INFER_MODE_FLOW,
INFER_MODE_AR,
INFER_MODE_FLOW_WITH_SUBTASK,
INFER_MODE_DLLM_FLOW,
INFER_MODE_DLLM_DD,
)
class WallXPolicy(BasePolicy):
"""Policy wrapper for Wall-X model that implements the BasePolicy interface."""
def __init__(
self,
config: InferConfig,
image_passing_mode: str = "base64",
default_infer_mode: str = INFER_MODE_FLOW,
serialize_actions: bool = True,
):
"""Initialize the Wall-X policy.
Args:
config: Inference configuration dataclass.
image_passing_mode: How images are passed from client ('base64' or 'numpy').
default_infer_mode: Default inference mode ('flow', 'ar', 'flow_with_subtask', etc.).
serialize_actions: If True, actions are serialized via robot_preprocessor;
if False, raw model output is returned directly.
"""
self.config = config
self.model_wrapper = WallxModelWrapper(config)
self.robot_preprocessor = self._register_robot_preprocessor()
self.image_passing_mode = image_passing_mode
self.default_infer_mode = default_infer_mode
self.serialize_actions = serialize_actions
logger.info(
"Image passing mode: %s, robot_type: %s, default_infer_mode: %s, "
"serialize_actions: %s, smooth_action: %s, smooth_gripper: %s",
self.image_passing_mode,
config.robot_type,
self.default_infer_mode,
self.serialize_actions,
config.smooth_action,
config.smooth_gripper,
)
def _register_robot_preprocessor(self):
"""Select preprocessor by config.robot_type; mirrors env._register_robot."""
if self.config.robot_type == "desktop":
return DesktopRobotPreprocessor(self.config)
if self.config.robot_type == "turtle":
return TurtleRobotPreprocessor(self.config)
if self.config.robot_type == "ex001":
return EX001RobotPreprocessor(self.config)
raise ValueError(f"Invalid robot_type: {self.config.robot_type!r}")
@staticmethod
def _decode_base64_image(image_b64: str) -> np.ndarray:
"""Decode client base64 JPEG/PNG payloads to RGB images."""
img_bytes = base64.b64decode(image_b64)
img_array = np.frombuffer(img_bytes, dtype=np.uint8)
decoded_img = cv2.imdecode(img_array, cv2.IMREAD_COLOR)
if decoded_img is None:
raise ValueError("Failed to decode base64 image payload")
return cv2.cvtColor(decoded_img, cv2.COLOR_BGR2RGB)
def reset(self) -> None:
"""Reset the policy state."""
self.action_buffer = []
self.buffer_index = 0
logger.debug("Policy reset")
def _get_dof_config(self) -> dict:
train_config = self.config.train_config or {}
return (
train_config.get("dof_config")
or train_config.get("task", {}).get("dof_config", {})
or {}
)
def _is_single_arm_right_only(self) -> bool:
"""True for single-arm LIBERO-style configs (right arm only, no left arm)."""
train_config = self.config.train_config or {}
agent_pos = (
train_config.get("agent_pos_config")
or train_config.get("task", {}).get("agent_pos_config")
or {}
)
skip = {"action_padding"}
has_left = any(k.startswith("follow_left_") for k in agent_pos if k not in skip)
has_right = any(
k.startswith("follow_right_") for k in agent_pos if k not in skip
)
return has_right and not has_left
def _pack_action_chunk_response(
self, model_output: Dict[str, Any]
) -> Dict[str, Any]:
"""Return a msgpack-safe action chunk for websocket clients."""
predict_action = model_output["predict_action"]
if isinstance(predict_action, torch.Tensor):
predict_action = predict_action.detach().cpu().numpy()
response: Dict[str, Any] = {
"predict_action": np.asarray(predict_action, dtype=np.float32),
}
if "subtask" in model_output:
response["subtask"] = model_output["subtask"]
return response
def _get_predict_action_keys(self) -> List[str]:
"""Resolve flat action key order for smoothing / serialization."""
data_cfg = self.config.data_config
keys = None
if isinstance(data_cfg, dict):
keys = data_cfg.get("predict_action_keys")
else:
keys = getattr(data_cfg, "predict_action_keys", None)
if keys:
return list(keys)
return list(self._get_dof_config().keys())
def _apply_smoothing(self, model_output: Dict[str, Any]) -> None:
"""Apply Laplacian smoothing on predict_action before 6D->euler conversion."""
cfg = self.config
if not getattr(cfg, "smooth_action", False):
return
dof_config = self._get_dof_config()
apply_smoothing(
model_output,
smooth_action=True,
smooth_gripper=cfg.smooth_gripper,
predict_action_keys=self._get_predict_action_keys(),
action_padding_dof=dof_config.get("action_padding"),
action_dim=cfg.action_dim,
)
def _run_action_infer(
self, observation: Dict, instruction: str, mode: str
) -> Dict[str, Any]:
"""Run model_wrapper action inference by mode; returns model_output with robot_state_action_data.
Supports flow | ar | flow_with_subtask. VQA can be added here later (different return format).
"""
if mode == INFER_MODE_FLOW:
return self.model_wrapper.infer_flow_action(observation, instruction)
if mode == INFER_MODE_AR:
return self.model_wrapper.infer_ar_action(observation, instruction)
if mode == INFER_MODE_FLOW_WITH_SUBTASK:
with ScopeTimer("infer_subtask"):
subtask = self.model_wrapper.infer_subtask(observation, instruction)
with ScopeTimer("infer_flow_action"):
model_output = self.model_wrapper.infer_flow_action(
observation, subtask
)
model_output["subtask"] = subtask
return model_output
if mode == INFER_MODE_DLLM_FLOW:
return self.model_wrapper.infer_dllm_action(
observation, instruction, use_ar_action=False
)
if mode == INFER_MODE_DLLM_DD:
return self.model_wrapper.infer_dllm_action(
observation, instruction, use_ar_action=True
)
raise ValueError(
f"Unsupported infer_mode={mode!r}, expected one of {ACTION_INFER_MODES}"
)
def infer(self, obs: Dict) -> Dict:
"""Infer action from observation.
Args:
obs: Dictionary containing:
- 'state': Robot state
- 'views': Camera views (keyed by camera name)
- 'instruction': Task instruction
- Optional: 'infer_mode' - one of 'flow' | 'ar' | 'flow_with_subtask'
- Optional: 'robot_action_start_ratio' / 'robot_action_end_ratio' /
'robot_action_interpolate_multiplier' - override config for action trim/interpolate
Returns:
When serialize_actions=True (default): Serialized action dict
(e.g. follow1_pos/follow2_pos or follow1_joints/follow2_joints).
When serialize_actions=False: Raw model_output dict.
If infer_mode is flow_with_subtask, also includes 'subtask'.
"""
state = obs["state"]
views = obs["views"]
instruction = obs.get("instruction") or self.config.default_instruction or ""
if self.image_passing_mode == "base64":
for k, v in views.items():
views[k] = np.expand_dims(self._decode_base64_image(v), axis=0)
with ScopeTimer("get_observation"):
observation = self.robot_preprocessor.get_observation(state, views)
mode = obs.get("infer_mode", self.default_infer_mode)
with ScopeTimer(f"infer_{mode}"):
model_output = self._run_action_infer(observation, instruction, mode)
self._apply_smoothing(model_output)
if (
"robot_state_action_data" in model_output
and "predict_action" in model_output
):
model_output["robot_state_action_data"].save_action_data(
model_output["predict_action"]
)
# Single-arm LIBERO has no left-arm EE to serialize; dual-arm follow1/follow2
# layout would fail in get_serialized_actions. Return raw action chunks instead.
if not self.serialize_actions or self._is_single_arm_right_only():
return self._pack_action_chunk_response(model_output)
return self.robot_preprocessor.get_serialized_actions(
model_output, robot_action_interpolate_multiplier=1
) # interpolation on robot websocket client
# -- Batch inference ----------------------------------------------
def _preprocess_obs(self, obs: Dict):
"""Preprocess a single observation dict into (observation, instruction).
Handles both base64 and raw image modes.
"""
state = obs["state"]
views = obs["views"]
instruction = obs.get("instruction") or self.config.default_instruction or ""
if self.image_passing_mode == "base64":
for k, v in views.items():
views[k] = np.expand_dims(self._decode_base64_image(v), axis=0)
else:
# raw numpy: ensure (1, H, W, C)
for k, img in views.items():
if isinstance(img, np.ndarray) and img.ndim == 3:
views[k] = np.expand_dims(img, axis=0)
observation = self.robot_preprocessor.get_observation(state, views)
return observation, instruction
def infer_batch(
self, obs_list: List[Dict[str, Any]], skip_serialize: bool = False
) -> List[Dict[str, Any]]:
"""Perform batch inference.
Args:
obs_list: List of observations, each containing:
- "views": dict of camera images
- "state": robot state dict or array
- "instruction": text instruction
Returns:
List of action dicts.
"""
batch_size = len(obs_list)
logger.info(f"WallXPolicy.infer_batch: processing {batch_size} observations")
try:
observations = []
instructions = []
for obs in obs_list:
observation, instruction = self._preprocess_obs(obs)
observations.append(observation)
instructions.append(instruction)
mode = obs_list[0].get("infer_mode", self.default_infer_mode)
with torch.no_grad():
if mode == INFER_MODE_FLOW:
model_outputs = self.model_wrapper.infer_flow_action_batch(
observations, instructions
)
else:
# AR / flow_with_subtask: fall back to per-sample inference
model_outputs = []
for obs_dict, instruction in zip(observations, instructions):
output = self._run_action_infer(obs_dict, instruction, mode)
model_outputs.append(output)
if skip_serialize:
return [{}] * len(model_outputs)
results = []
for model_output in model_outputs:
self._apply_smoothing(model_output)
if self.serialize_actions and not self._is_single_arm_right_only():
action = self.robot_preprocessor.get_serialized_actions(
model_output, robot_action_interpolate_multiplier=1
)
results.append(action)
else:
results.append(self._pack_action_chunk_response(model_output))
return results
except Exception as e:
logger.error(f"Batch inference failed: {e}", exc_info=True)
return [{"action": {}, "error": str(e)} for _ in obs_list]
@property
def metadata(self) -> Dict[str, Any]:
return {"batch_enabled": True, "model": "wall-x"}
+160
View File
@@ -0,0 +1,160 @@
import asyncio
import logging
import time
import uuid
from collections import deque
from dataclasses import dataclass
from typing import Any, Dict, List
logger = logging.getLogger(__name__)
@dataclass
class Request:
request_id: str
obs: Dict[str, Any]
future: asyncio.Future
timestamp: float
class RequestScheduler:
def __init__(
self,
policy,
max_batch_size: int = 8,
max_wait_time_ms: float = 100,
max_queue_size: int = 128,
timeout_ms: float = 5000,
):
self.policy = policy
self.max_batch_size = max_batch_size
self.max_wait_time = max_wait_time_ms / 1000.0
self.max_queue_size = max_queue_size
self.timeout = timeout_ms / 1000.0
self.queue = deque()
self.queue_lock = asyncio.Lock()
self.queue_not_empty = asyncio.Condition(self.queue_lock)
self.running = False
self.batch_task = None
async def start(self):
self.running = True
self.batch_task = asyncio.create_task(self._batch_loop())
logger.info(
f"RequestScheduler started: max_batch={self.max_batch_size}, max_wait={self.max_wait_time*1000}ms"
)
async def stop(self):
self.running = False
# Process all remaining requests before shutting down
async with self.queue_lock:
remaining = len(self.queue)
if remaining:
logger.info(
f"Graceful shutdown: processing {remaining} remaining request(s)"
)
while True:
async with self.queue_lock:
if len(self.queue) == 0:
break
batch = []
while self.queue and len(batch) < self.max_batch_size:
batch.append(self.queue.popleft())
if batch:
await self._process_batch(batch)
# Now stop the batch loop
async with self.queue_lock:
self.queue_not_empty.notify_all()
if self.batch_task:
await self.batch_task
logger.info("RequestScheduler stopped")
async def add_request(self, obs: Dict[str, Any]) -> Dict[str, Any]:
request_id = str(uuid.uuid4())
future = asyncio.Future()
request = Request(
request_id=request_id, obs=obs, future=future, timestamp=time.monotonic()
)
async with self.queue_lock:
if len(self.queue) >= self.max_queue_size:
raise RuntimeError(
f"Queue full: {len(self.queue)}/{self.max_queue_size}"
)
self.queue.append(request)
self.queue_not_empty.notify()
try:
result = await asyncio.wait_for(future, timeout=self.timeout)
return result
except asyncio.TimeoutError:
logger.error(f"Request {request_id} timeout after {self.timeout}s")
raise
async def _batch_loop(self):
while self.running:
try:
batch = await self._collect_batch()
if batch:
await self._process_batch(batch)
except Exception as e:
logger.error(f"Unexpected error in batch loop: {e}", exc_info=True)
async def _collect_batch(self) -> List[Request]:
async with self.queue_not_empty:
while self.running and len(self.queue) == 0:
await self.queue_not_empty.wait()
if not self.running:
return []
batch = []
deadline = time.monotonic() + self.max_wait_time
while len(batch) < self.max_batch_size:
if len(self.queue) > 0:
batch.append(self.queue.popleft())
if len(batch) >= self.max_batch_size:
break
if len(self.queue) == 0:
remaining = deadline - time.monotonic()
if remaining <= 0:
break
try:
await asyncio.wait_for(
self.queue_not_empty.wait(), timeout=remaining
)
except asyncio.TimeoutError:
break
return batch
async def _process_batch(self, batch: List[Request]):
if not batch:
return
start_time = time.monotonic()
logger.info(f"Processing batch of {len(batch)} requests")
try:
obs_list = [req.obs for req in batch]
results = await asyncio.to_thread(self.policy.infer_batch, obs_list)
for req, result in zip(batch, results):
if not req.future.done():
req.future.set_result(result)
infer_time = time.monotonic() - start_time
logger.info(
f"Batch processed in {infer_time*1000:.1f}ms, throughput: {len(batch)/infer_time:.1f} req/s"
)
except Exception as e:
logger.error(f"Batch processing failed: {e}", exc_info=True)
for req in batch:
if not req.future.done():
req.future.set_exception(e)
@@ -0,0 +1,184 @@
import asyncio
import http
import logging
import time
import traceback
from typing import Dict, Any, List
try:
import msgpack
import msgpack_numpy as m
m.patch()
except ImportError:
logging.warning(
"msgpack-numpy not installed. Install with: pip install msgpack-numpy"
)
msgpack = None
import websockets.asyncio.server as _server
import websockets.frames
logger = logging.getLogger(__name__)
class BasePolicy:
"""Base class for policies that can be served."""
def infer(self, obs: Dict) -> Dict:
"""Infer actions from observations."""
raise NotImplementedError
def infer_batch(self, obs_list: List[Dict]) -> List[Dict]:
"""Batch inference. Default implementation calls infer() per sample."""
return [self.infer(obs) for obs in obs_list]
def reset(self) -> None:
"""Reset the policy to its initial state."""
pass
@property
def metadata(self) -> Dict[str, Any]:
"""Return metadata about the policy."""
return {}
class WebsocketPolicyServer:
"""Serves a policy using the websocket protocol.
Implements a websocket server that:
1. Sends policy metadata on connection
2. Receives observations
3. Returns predicted actions (single or batched)
4. Tracks timing information
When batching parameters are provided, dynamic batching is enabled:
requests from multiple clients are queued and processed in batches.
"""
def __init__(
self,
policy: BasePolicy,
host: str = "0.0.0.0",
port: int = 8000,
metadata: Dict | None = None,
# Dynamic batching parameters (None = disabled)
max_batch_size: int | None = None,
max_wait_time_ms: float | None = None,
max_queue_size: int = 100,
timeout_ms: float = 30000,
) -> None:
self._policy = policy
self._host = host
self._port = port
self._metadata = metadata or {}
# Dynamic batching
self._scheduler = None
if max_batch_size is not None:
from .scheduler import RequestScheduler
self._scheduler = RequestScheduler(
policy=policy,
max_batch_size=max_batch_size,
max_wait_time_ms=(
max_wait_time_ms if max_wait_time_ms is not None else 0
),
max_queue_size=max_queue_size,
timeout_ms=timeout_ms,
)
logging.getLogger("websockets.server").setLevel(logging.INFO)
@property
def batching_enabled(self) -> bool:
return self._scheduler is not None
def serve_forever(self) -> None:
asyncio.run(self.run())
async def run(self):
# Start the scheduler if batching is enabled
if self._scheduler is not None:
await self._scheduler.start()
try:
async with _server.serve(
self._handler,
self._host,
self._port,
compression=None,
max_size=None,
ping_interval=None,
ping_timeout=None,
process_request=_health_check,
) as server:
mode_str = "batched" if self.batching_enabled else "single"
logger.info(
f"Server started on {self._host}:{self._port} (mode={mode_str})"
)
await server.serve_forever()
finally:
if self._scheduler is not None:
await self._scheduler.stop()
async def _handler(self, websocket: _server.ServerConnection):
logger.info(f"Connection from {websocket.remote_address} opened")
if msgpack is None:
await websocket.close(
code=websockets.frames.CloseCode.INTERNAL_ERROR,
reason="msgpack-numpy not installed on server",
)
return
# Send metadata to client
await websocket.send(msgpack.packb(self._metadata))
prev_total_time = None
while True:
try:
start_time = time.monotonic()
obs = msgpack.unpackb(await websocket.recv())
infer_time = time.monotonic()
if self._scheduler is not None:
# Dynamic batching path
action = await self._scheduler.add_request(obs)
else:
# Single inference path
action = await asyncio.to_thread(self._policy.infer, obs)
infer_time = time.monotonic() - infer_time
action["server_timing"] = {
"infer_ms": infer_time * 1000,
}
if prev_total_time is not None:
action["server_timing"]["prev_total_ms"] = prev_total_time * 1000
await websocket.send(msgpack.packb(action))
prev_total_time = time.monotonic() - start_time
except websockets.ConnectionClosed:
logger.info(f"Connection from {websocket.remote_address} closed")
break
except Exception as e:
logger.error(f"Error handling request: {e}")
await websocket.send(traceback.format_exc())
await websocket.close(
code=websockets.frames.CloseCode.INTERNAL_ERROR,
reason="Internal server error. Traceback included in previous frame.",
)
raise
def _health_check(
connection: _server.ServerConnection, request: _server.Request
) -> _server.Response | None:
if request.path == "/healthz":
return connection.respond(http.HTTPStatus.OK, "OK\n")
if request.path == "/v2/health/ready":
return connection.respond(http.HTTPStatus.OK, "OK\n")
return None