#!/usr/bin/env python3 """Export JAX/Flax-trained SKRL Go1 (no-linevel, 57-dim) policy to ONNX. Converts Flax weights → PyTorch → ONNX, baking in the RunningStandardScaler normalization so the ONNX model accepts raw (scaled) observations directly. Usage: uv run scripts/export_go1_no_linevel_onnx.py uv run scripts/export_go1_no_linevel_onnx.py --output ./my_exports """ import argparse import os import pickle from pathlib import Path import msgpack import numpy as np def _decode_flax_array(ext) -> np.ndarray | None: if not hasattr(ext, "code"): return None parts = msgpack.unpackb(ext.data, raw=False) if not isinstance(parts, list) or len(parts) < 3: return None def _flatten(s): if isinstance(s, list): out = [] for item in s: out.extend(_flatten(item)) return out return [s] shape = tuple(_flatten(parts[0])) dtype_str = parts[1] if isinstance(dtype_str, bytes): dtype_str = dtype_str.decode("utf-8") raw = parts[2] return np.frombuffer(raw, dtype=np.dtype(dtype_str)).reshape(shape) def load_jax_checkpoint(ckpt_path: str) -> dict: with open(ckpt_path, "rb") as f: data = pickle.load(f) policy_raw = msgpack.unpackb(data["policy"]) flax_params = {} for name, val in policy_raw["params"].items(): if isinstance(val, dict): flax_params[name] = {k: _decode_flax_array(v) for k, v in val.items()} else: flax_params[name] = _decode_flax_array(val) prep = msgpack.unpackb(data["state_preprocessor"]) running_mean = _decode_flax_array(prep["running_mean"]) running_var = _decode_flax_array(prep["running_variance"]) count_arr = _decode_flax_array(prep["current_count"]) count = int(count_arr.flat[0]) if count_arr is not None else 0 return { "flax_params": flax_params, "running_mean": running_mean, "running_var": running_var, "count": count, } # -- PyTorch model ----------------------------------------------------------- import torch import torch.nn as nn class PolicyTorch(nn.Module): def __init__(self, obs_dim: int, action_dim: int, hidden_dims: list[int]): super().__init__() self.obs_dim = obs_dim self.action_dim = action_dim self.hidden_dims = hidden_dims layers = [] in_dim = obs_dim for h in hidden_dims: layers.extend([nn.Linear(in_dim, h), nn.ELU()]) in_dim = h self.net = nn.Sequential(*layers) self.mean_layer = nn.Linear(in_dim, action_dim) def forward(self, x): return self.mean_layer(self.net(x)) class ONNXExporter(nn.Module): def __init__(self, policy: PolicyTorch, mean: np.ndarray, std: np.ndarray): super().__init__() self.policy = policy self.register_buffer("mean", torch.from_numpy(mean).float()) self.register_buffer("std", torch.from_numpy(std).float()) self.clip_threshold = 5.0 def forward(self, x): x = (x - self.mean) / (self.std + 1e-8) x = torch.clamp(x, min=-self.clip_threshold, max=self.clip_threshold) return self.policy(x) def flax_to_torch_weights(flax_params: dict, obs_dim: int, hidden_dims: list[int], action_dim: int) -> dict: state_dict = {} layer_names = sorted([k for k in flax_params if k.startswith("Dense_")]) hidden_dense = layer_names[:-1] layer_idx = 0 for name in hidden_dense: layer_params = flax_params[name] kernel = layer_params["kernel"] bias = layer_params["bias"] state_dict[f"net.{layer_idx}.weight"] = torch.from_numpy(kernel.T.copy()).float() state_dict[f"net.{layer_idx}.bias"] = torch.from_numpy(bias.copy()).float() layer_idx += 2 last_name = layer_names[-1] last_params = flax_params[last_name] state_dict["mean_layer.weight"] = torch.from_numpy(last_params["kernel"].T.copy()).float() state_dict["mean_layer.bias"] = torch.from_numpy(last_params["bias"].copy()).float() return state_dict # -- Config ------------------------------------------------------------------ GO1_JOINT_NAMES = [ "FR_hip", "FR_thigh", "FR_calf", "FL_hip", "FL_thigh", "FL_calf", "RR_hip", "RR_thigh", "RR_calf", "RL_hip", "RL_thigh", "RL_calf", ] GO1_DEFAULT_ANGLES = np.array([ -0.0, 0.9, -1.8, 0.0, 0.9, -1.8, -0.0, 0.9, -1.8, 0.0, 0.9, -1.8, ], dtype=np.float32) # 57-dim observation layout (NO linear velocity): # [0:3] gyro (scaled *0.25) # [3:6] gravity vector (body frame) # [6:18] joint angle deviation (scaled *1.0) # [18:30] joint velocity (scaled *0.05) # [30:42] last action (raw) # [42:45] command [vx*2.0, vy*2.0, wz*0.25] # [45:57] foot contact forces (body frame, raw) OBS_SCALES = { "ang_vel": 0.25, "dof_pos": 1.0, "dof_vel": 0.05, "contact_force": 1.0, # raw, no scaling } ACTION_SCALE = 0.05 KP, KD = 80.0, 1.0 CLIP_ACTIONS = 23.7 CLIP_OBS = 100.0 def auto_discover_checkpoint(env_name: str) -> str: """Find the latest best_agent checkpoint for the given env.""" base_dir = Path(f"runs/{env_name}/skrl") if not base_dir.exists(): raise FileNotFoundError(f"No training results found: {base_dir}") runs = sorted([d for d in base_dir.iterdir() if d.is_dir()], key=lambda d: d.stat().st_mtime, reverse=True) for run_dir in runs: ckpt = run_dir / "checkpoints" / "best_agent.pickle" if ckpt.exists(): return str(ckpt) raise FileNotFoundError(f"No best_agent.pickle found in {base_dir}") def export(checkpoint_path: str, output_dir: str): ckpt = load_jax_checkpoint(checkpoint_path) flax_params = ckpt["flax_params"] running_mean = ckpt["running_mean"] running_var = ckpt["running_var"] running_std = np.sqrt(running_var) dense_keys = sorted([k for k in flax_params if k.startswith("Dense_")]) hidden_dims = [flax_params[k]["bias"].shape[0] for k in dense_keys[:-1]] obs_dim = flax_params[dense_keys[0]]["kernel"].shape[0] action_dim = flax_params[dense_keys[-1]]["bias"].shape[0] print(f"Architecture: obs={obs_dim}, hidden={hidden_dims}, action={action_dim}") print(f"Normalizer mean range: [{running_mean.min():.4f}, {running_mean.max():.4f}]") print(f"Normalizer std range: [{running_std.min():.6f}, {running_std.max():.6f}]") policy = PolicyTorch(obs_dim, action_dim, hidden_dims) torch_weights = flax_to_torch_weights(flax_params, obs_dim, hidden_dims, action_dim) policy.load_state_dict(torch_weights, strict=True) policy.eval() rng = np.random.RandomState(42) test_obs = rng.randn(1, obs_dim).astype(np.float32) with torch.no_grad(): torch_out = policy(torch.from_numpy(test_obs)).numpy() print(f"Test forward pass: input shape={test_obs.shape}, output shape={torch_out.shape}") print(f" output sample: {np.array2string(torch_out[0, :4], precision=4, suppress_small=True)} ...") os.makedirs(output_dir, exist_ok=True) onnx_path = os.path.join(output_dir, "policy.onnx") exporter = ONNXExporter(policy, running_mean, running_std) exporter.eval() dummy = torch.zeros(1, obs_dim, dtype=torch.float32) torch.onnx.export( exporter, dummy, onnx_path, export_params=True, opset_version=11, input_names=["observations"], output_names=["actions"], dynamic_axes={}, ) print(f"ONNX exported to: {onnx_path}") # Normalizer stats npz_path = os.path.join(output_dir, "normalizer.npz") np.savez(npz_path, mean=running_mean, std=running_std) print(f"Normalizer saved to: {npz_path}") # Metadata meta_path = os.path.join(output_dir, "metadata.txt") with open(meta_path, "w") as f: f.write("# Go1 No-Linevel Terrain Walk - ONNX Policy Metadata\n") f.write(f"env: go1-stairs-terrain-walk-no-linevel\n") f.write(f"obs_dim: {obs_dim}\n") f.write(f"action_dim: {action_dim}\n") f.write(f"hidden_dims: {hidden_dims}\n") f.write("\n# Observation layout (57 dims, NO linear velocity):\n") f.write(" [0:3] gyro * ang_vel_scale\n") f.write(" [3:6] gravity (body frame)\n") f.write(" [6:18] joint_angle_deviation * dof_pos_scale\n") f.write(" [18:30] joint_vel * dof_vel_scale\n") f.write(" [30:42] last_actions (raw)\n") f.write(" [42:45] commands [vx*2.0, vy*2.0, wz*0.25]\n") f.write(" [45:57] foot_contact_forces (body frame, raw)\n") f.write(f"\n# Joint order: {GO1_JOINT_NAMES}\n") f.write(f"default_angles: {GO1_DEFAULT_ANGLES.tolist()}\n") f.write(f"action_scale: {ACTION_SCALE}\n") f.write(f"kp: {KP}\n") f.write(f"kd: {KD}\n") f.write(f"clip_actions: {CLIP_ACTIONS}\n") f.write(f"clip_observations: {CLIP_OBS}\n") f.write("\n# Observation scales:\n") for k, v in OBS_SCALES.items(): f.write(f" {k}: {v}\n") f.write(" command_scale: [2.0, 2.0, 0.25]\n") print(f"Metadata saved to: {meta_path}") return onnx_path def main(): parser = argparse.ArgumentParser(description="Export JAX-trained Go1 no-linevel policy to ONNX") parser.add_argument("--checkpoint", type=str, default=None, help="Path to SKRL JAX checkpoint (auto-discovered if not set)") parser.add_argument("--output", type=str, default="exports_go1_no_linevel", help="Output directory") args = parser.parse_args() if args.checkpoint: ckpt_path = args.checkpoint else: ckpt_path = auto_discover_checkpoint("go1-stairs-terrain-walk-no-linevel") if not os.path.exists(ckpt_path): print(f"Error: checkpoint not found: {ckpt_path}") return 1 print(f"Loading checkpoint: {ckpt_path}") onnx_path = export(ckpt_path, args.output) print(f"\nDone! ONNX model ready for sim2sim:") print(f" {onnx_path}") return 0 if __name__ == "__main__": exit(main())