Initial commit
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
"""Export an S10 SuperMimic actor checkpoint to ONNX + deployment JSON.
|
||||
|
||||
Example:
|
||||
python scripts/export_onnx.py \
|
||||
--checkpoint logs/s10_terrain_v4_20k_full/model_20000.pt \
|
||||
--output logs/s10_terrain_v4_20k_full/model_20000.onnx
|
||||
|
||||
The ONNX file contains only the deterministic actor (RunningNorm + MLP + tanh).
|
||||
Input ``obs`` is [batch, 265] for v4/v4-upward and for the new v7 (wheel speed
|
||||
restored); only legacy v7 checkpoints use [batch, 245]. Output is [batch, 16].
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from mjlab.utils.string import resolve_expr
|
||||
|
||||
from s10_supermimic.core.policy import load_actor
|
||||
from s10_supermimic.robot.config import (
|
||||
LEG_JOINT_NAMES, WHEEL_JOINT_NAMES, get_s10_robot_cfg,
|
||||
)
|
||||
|
||||
|
||||
def task_matches_actor(task, actor_wheel_vel: bool, frame_dim: int) -> bool:
|
||||
return task.actor.wheel_vel == actor_wheel_vel and task.actor.frame_dim == frame_dim
|
||||
|
||||
|
||||
def default_joint_pos(checkpoint, actor_wheel_vel: bool, frame_dim: int) -> list[float]:
|
||||
"""Use the checkpoint task's default pose when its Actor contract matches.
|
||||
|
||||
Legacy 49-value v7 checkpoints predate the ±5° safe stance, so they fall
|
||||
back to the legacy v4 ±20° pose instead of silently exporting a new target.
|
||||
"""
|
||||
robot_cfg = get_s10_robot_cfg()
|
||||
task_name = checkpoint.get("config", {}).get("task")
|
||||
if task_name:
|
||||
from s10_supermimic.tasks import get_task
|
||||
try:
|
||||
task = get_task(task_name)
|
||||
if task_matches_actor(task, actor_wheel_vel, frame_dim):
|
||||
cfg = task.build_env_cfg(
|
||||
play=True, history=int(checkpoint["history"]),
|
||||
seed=int(checkpoint.get("config", {}).get("seed", 0)))
|
||||
robot_cfg = cfg.scene.entities["robot"]
|
||||
except KeyError:
|
||||
pass
|
||||
names = LEG_JOINT_NAMES + WHEEL_JOINT_NAMES
|
||||
return [float(v) for v in resolve_expr(robot_cfg.init_state.joint_pos, names, 0.0)]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
p = argparse.ArgumentParser(description=__doc__)
|
||||
p.add_argument("--checkpoint", type=Path, required=True)
|
||||
p.add_argument("--output", type=Path, default=None,
|
||||
help="ONNX path; defaults to <checkpoint>.onnx")
|
||||
p.add_argument("--opset", type=int, default=17)
|
||||
p.add_argument("--device", default="cpu")
|
||||
args = p.parse_args()
|
||||
|
||||
ckpt = torch.load(args.checkpoint, map_location=args.device, weights_only=True)
|
||||
actor = load_actor(ckpt, args.device).eval()
|
||||
history = int(ckpt["history"])
|
||||
if ckpt.get("format") not in ("s10-flat-policy-v1", "s10-terrain-policy-v1"):
|
||||
raise ValueError(f"Not an S10 policy checkpoint: {ckpt.get('format')}")
|
||||
if history < 1:
|
||||
raise ValueError("checkpoint history must be positive")
|
||||
|
||||
output = args.output or args.checkpoint.with_suffix(".onnx")
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
dummy = torch.zeros(1, actor.frame_dim * history, device=args.device)
|
||||
|
||||
with torch.no_grad():
|
||||
reference = actor(dummy).cpu().numpy()
|
||||
|
||||
torch.onnx.export(
|
||||
actor,
|
||||
(dummy,),
|
||||
str(output),
|
||||
input_names=["obs"],
|
||||
output_names=["actions"],
|
||||
opset_version=args.opset,
|
||||
dynamo=False,
|
||||
do_constant_folding=True,
|
||||
dynamic_axes={"obs": {0: "batch"}, "actions": {0: "batch"}},
|
||||
)
|
||||
|
||||
import onnx
|
||||
import onnxruntime as ort
|
||||
onnx.checker.check_model(str(output))
|
||||
session = ort.InferenceSession(str(output), providers=["CPUExecutionProvider"])
|
||||
onnx_out = session.run(["actions"], {"obs": dummy.cpu().numpy()})[0]
|
||||
max_error = float(np.max(np.abs(onnx_out - reference)))
|
||||
if max_error > 1e-5:
|
||||
raise RuntimeError(f"ONNX/PyTorch mismatch: max error {max_error}")
|
||||
|
||||
revision = int(ckpt.get("config", {}).get("terrain_revision", 1))
|
||||
leg_scales = ([0.25, 0.6, 0.8] * 4) if revision >= 2 else ([0.25] * 12)
|
||||
wheel_scale = 35.0 if revision >= 3 else 15.0
|
||||
task_name = ckpt.get("config", {}).get("task")
|
||||
task = None
|
||||
if task_name:
|
||||
from s10_supermimic.tasks import get_task
|
||||
try:
|
||||
task = get_task(task_name)
|
||||
except KeyError:
|
||||
task = None
|
||||
if task is not None and task_matches_actor(task, actor.actor_wheel_vel, actor.frame_dim):
|
||||
actor_schema = task.actor.name
|
||||
else:
|
||||
actor_schema = "s10-v4-53" if actor.actor_wheel_vel else "s10-v7-49"
|
||||
sidecar = {
|
||||
"format": ckpt["format"],
|
||||
"step": ckpt.get("step"),
|
||||
"history": history,
|
||||
"actor_schema": actor_schema,
|
||||
"actor_wheel_vel": actor.actor_wheel_vel,
|
||||
"frame_dim": actor.frame_dim,
|
||||
"observation_dim": actor.frame_dim * history,
|
||||
"action_dim": 16,
|
||||
"dt": ckpt.get("dt", 0.02),
|
||||
"actor_hidden": list(ckpt.get("actor_hidden", ())),
|
||||
"actor_params": sum(x.numel() for x in actor.parameters()),
|
||||
"frame_terms": [
|
||||
{"name": "base_ang_vel", "dim": 3, "scale": 1.0},
|
||||
{"name": "projected_gravity", "dim": 3, "scale": 1.0},
|
||||
{"name": "joint_pos_rel", "dim": 12, "scale": 1.0},
|
||||
{"name": "joint_vel_rel", "dim": 12, "scale": 0.05},
|
||||
*([{"name": "wheel_vel_rel", "dim": 4, "scale": 0.05}]
|
||||
if actor.actor_wheel_vel else []),
|
||||
{"name": "last_action", "dim": 16, "scale": 1.0},
|
||||
{"name": "command", "dim": 3, "scale": 1.0},
|
||||
],
|
||||
"history_layout": "term-wise blocks, oldest to newest (mjlab flattening)",
|
||||
"env_action_order": list(ckpt["env_action_order"]),
|
||||
"leg_scale": leg_scales,
|
||||
"wheel_scale": wheel_scale,
|
||||
"wheel_clip": 35.0,
|
||||
"default_joint_pos": default_joint_pos(
|
||||
ckpt, actor.actor_wheel_vel, actor.frame_dim),
|
||||
"leg_joint_names": list(LEG_JOINT_NAMES),
|
||||
"wheel_joint_names": list(WHEEL_JOINT_NAMES),
|
||||
"onnx": {
|
||||
"input": "obs",
|
||||
"output": "actions",
|
||||
"opset": args.opset,
|
||||
"max_abs_error_vs_torch": max_error,
|
||||
},
|
||||
}
|
||||
sidecar_path = output.with_suffix(".json")
|
||||
sidecar_path.write_text(json.dumps(sidecar, indent=2) + "\n")
|
||||
print(f"Exported ONNX: {output}")
|
||||
print(f"Deployment metadata: {sidecar_path}")
|
||||
print(f"ONNX/PyTorch max error: {max_error:.3e}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user