Add Wall-X serving and Turtle2 TCP WebSocket bridge
Pre-commit / pre-commit (push) Canceled after 0s
Pre-commit / pre-commit (push) Canceled after 0s
This commit is contained in:
@@ -128,6 +128,18 @@ class WallXPolicy(BasePolicy):
|
||||
response: Dict[str, Any] = {
|
||||
"predict_action": np.asarray(predict_action, dtype=np.float32),
|
||||
}
|
||||
# Single-arm checkpoints normally return raw model output because the
|
||||
# generic serializer expects both arms. Still expose the official
|
||||
# right-arm reconstruction (absolute xyz/euler/gripper) so legacy
|
||||
# clients do not have to guess relative-action semantics.
|
||||
state_action = model_output.get("robot_state_action_data")
|
||||
if state_action is not None and self._is_single_arm_right_only():
|
||||
try:
|
||||
response["follow2_pos"] = self.robot_preprocessor._get_right_arm_action(
|
||||
state_action
|
||||
).astype(np.float32)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not serialize single-arm right action: %s", exc)
|
||||
if "subtask" in model_output:
|
||||
response["subtask"] = model_output["subtask"]
|
||||
return response
|
||||
|
||||
@@ -0,0 +1,472 @@
|
||||
import base64
|
||||
import logging
|
||||
import threading
|
||||
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_rtc 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
|
||||
from wall_x._vendor.harrix.serving.rtc_wallx import WallXRTCConfig
|
||||
from wall_x._vendor.x2robot_utils import geometry as geom
|
||||
|
||||
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,
|
||||
rtc_config: WallXRTCConfig | None = None,
|
||||
):
|
||||
"""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.rtc_config = rtc_config or WallXRTCConfig()
|
||||
self.model_wrapper = WallxModelWrapper(config, rtc_config=self.rtc_config)
|
||||
self.robot_preprocessor = self._register_robot_preprocessor()
|
||||
self._rtc_sessions: dict[str, dict[str, Any]] = {}
|
||||
self._rtc_infer_lock = threading.Lock()
|
||||
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),
|
||||
}
|
||||
# Single-arm checkpoints normally return raw model output because the
|
||||
# generic serializer expects both arms. Still expose the official
|
||||
# right-arm reconstruction (absolute xyz/euler/gripper) so legacy
|
||||
# clients do not have to guess relative-action semantics.
|
||||
state_action = model_output.get("robot_state_action_data")
|
||||
if state_action is not None and self._is_single_arm_right_only():
|
||||
try:
|
||||
response["follow2_pos"] = self.robot_preprocessor._get_right_arm_action(
|
||||
state_action
|
||||
).astype(np.float32)
|
||||
except Exception as exc:
|
||||
logger.warning("Could not serialize single-arm right action: %s", exc)
|
||||
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 _rtc_build_normalized_prefix(self, state: Dict, rtc: Dict):
|
||||
session_id = str(rtc.get("session_id", "default"))
|
||||
if rtc.get("reset"):
|
||||
self._rtc_sessions.pop(session_id, None)
|
||||
return None
|
||||
cached = self._rtc_sessions.get(session_id)
|
||||
if not cached:
|
||||
return None
|
||||
|
||||
consumed = max(0, int(rtc.get("consumed_model_steps", 0)))
|
||||
absolute = np.asarray(cached["follow2_pos"], dtype=np.float64)
|
||||
if consumed >= len(absolute):
|
||||
return None
|
||||
absolute = absolute[consumed:]
|
||||
current = np.asarray(state["follow2_pos"], dtype=np.float64).reshape(7)
|
||||
|
||||
state_rotation = geom.euler_to_matrix_zyx_batch_nb(
|
||||
current[None, 3:6]
|
||||
)[0]
|
||||
absolute_rotation = geom.euler_to_matrix_zyx_batch_nb(absolute[:, 3:6])
|
||||
delta_rotation = absolute_rotation @ state_rotation.T
|
||||
delta_rotation_6d = delta_rotation[:, :2, :].reshape(len(absolute), 6)
|
||||
|
||||
dof_config = self._get_dof_config()
|
||||
columns = []
|
||||
for key, width in dof_config.items():
|
||||
width = int(width)
|
||||
lowered = key.lower()
|
||||
if key == "action_padding":
|
||||
values = np.zeros((len(absolute), width), dtype=np.float64)
|
||||
elif "follow_right" in lowered and "cartesian_pos" in lowered:
|
||||
values = absolute[:, :3] - current[None, :3]
|
||||
elif "follow_right" in lowered and "rotation_6d" in lowered:
|
||||
values = delta_rotation_6d
|
||||
elif "follow_right" in lowered and "rotation" in lowered:
|
||||
raise ValueError("RTC currently requires the checkpoint's 6D rotation layout")
|
||||
elif "follow_right" in lowered and "gripper" in lowered:
|
||||
values = absolute[:, 6:7]
|
||||
else:
|
||||
values = np.zeros((len(absolute), width), dtype=np.float64)
|
||||
if values.shape[1] != width:
|
||||
raise ValueError(
|
||||
f"RTC field {key!r} expected width {width}, got {values.shape[1]}"
|
||||
)
|
||||
columns.append(values)
|
||||
|
||||
raw = np.concatenate(columns, axis=1)
|
||||
tensor = torch.from_numpy(raw).to(
|
||||
device=self.config.model_device, dtype=torch.float32
|
||||
).unsqueeze(0)
|
||||
return self.model_wrapper.normalizer_action.normalize_data(
|
||||
tensor, [self.model_wrapper.norm_key]
|
||||
)
|
||||
|
||||
def _rtc_update_session(
|
||||
self,
|
||||
*,
|
||||
state: Dict,
|
||||
rtc: Dict,
|
||||
model_output: Dict[str, Any],
|
||||
) -> None:
|
||||
session_id = str(rtc.get("session_id", "default"))
|
||||
state_action = model_output.get("robot_state_action_data")
|
||||
if state_action is None:
|
||||
return
|
||||
absolute = self.robot_preprocessor._get_right_arm_action(state_action)
|
||||
absolute = np.asarray(absolute, dtype=np.float64)
|
||||
if len(absolute) == self.config.action_horizon + 1:
|
||||
absolute = absolute[1:]
|
||||
self._rtc_sessions[session_id] = {
|
||||
"follow2_pos": absolute.copy(),
|
||||
"chunk_id": int(rtc.get("request_id", 0)),
|
||||
}
|
||||
|
||||
def _run_action_infer(
|
||||
self,
|
||||
observation: Dict,
|
||||
instruction: str,
|
||||
mode: str,
|
||||
*,
|
||||
rtc_context: Dict | None = None,
|
||||
rtc_prefix=None,
|
||||
) -> 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:
|
||||
rtc_context = rtc_context or {}
|
||||
return self.model_wrapper.infer_flow_action(
|
||||
observation,
|
||||
instruction,
|
||||
prev_chunk_left_over=rtc_prefix,
|
||||
inference_delay=int(rtc_context.get("inference_delay_steps", 0)),
|
||||
execution_horizon=int(
|
||||
rtc_context.get(
|
||||
"execution_horizon",
|
||||
self.rtc_config.execution_horizon,
|
||||
)
|
||||
),
|
||||
)
|
||||
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:
|
||||
with self._rtc_infer_lock:
|
||||
return self._infer_locked(obs)
|
||||
|
||||
def _infer_locked(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)
|
||||
rtc_context = dict(obs.get("rtc") or {})
|
||||
rtc_prefix = self._rtc_build_normalized_prefix(state, rtc_context)
|
||||
with ScopeTimer(f"infer_{mode}"):
|
||||
model_output = self._run_action_infer(
|
||||
observation,
|
||||
instruction,
|
||||
mode,
|
||||
rtc_context=rtc_context,
|
||||
rtc_prefix=rtc_prefix,
|
||||
)
|
||||
|
||||
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"]
|
||||
)
|
||||
|
||||
self._rtc_update_session(
|
||||
state=state,
|
||||
rtc=rtc_context,
|
||||
model_output=model_output,
|
||||
)
|
||||
|
||||
# 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():
|
||||
response = self._pack_action_chunk_response(model_output)
|
||||
else:
|
||||
response = self.robot_preprocessor.get_serialized_actions(
|
||||
model_output, robot_action_interpolate_multiplier=1
|
||||
)
|
||||
response["_rtc"] = {
|
||||
"session_id": str(rtc_context.get("session_id", "default")),
|
||||
"request_id": int(rtc_context.get("request_id", 0)),
|
||||
"guided": rtc_prefix is not None,
|
||||
"inference_delay_steps": int(
|
||||
rtc_context.get("inference_delay_steps", 0)
|
||||
),
|
||||
"execution_horizon": int(
|
||||
rtc_context.get(
|
||||
"execution_horizon",
|
||||
self.rtc_config.execution_horizon,
|
||||
)
|
||||
),
|
||||
}
|
||||
return response
|
||||
|
||||
# -- 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"}
|
||||
Reference in New Issue
Block a user