Skip to content

Commit 8aedc14

Browse files
committed
Run DR Legs policy with Warp-NN
Replace the PyTorch policy and observation path with native Warp arrays and ONNX inference. Pin the asset revision containing the converted model so the example works from a fresh checkout. Keep floating-root coordinates out of the 94-value policy input and lazily create Torch views for other RL examples.
1 parent d37f4d3 commit 8aedc14

8 files changed

Lines changed: 604 additions & 248 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Run the Kamino DR Legs RL example from an ONNX model with Warp-NN instead of loading a PyTorch policy checkpoint.

newton/_src/solvers/kamino/examples/rl/example_rl_drlegs.py

Lines changed: 284 additions & 203 deletions
Large diffs are not rendered by default.

newton/_src/solvers/kamino/examples/rl/joystick.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -38,17 +38,55 @@
3838

3939
# Python
4040
import dataclasses
41+
import math
4142

42-
# Thirdparty
43-
import torch # noqa: TID253
4443

45-
from newton._src.solvers.kamino.examples.rl.utils import (
46-
RateLimitedValue,
47-
_deadband,
48-
_LowPassFilter,
49-
_scale_asym,
50-
yaw_apply_2d,
51-
)
44+
def _deadband(value: float, threshold: float) -> float:
45+
if abs(value) < threshold:
46+
return 0.0
47+
return math.copysign((abs(value) - threshold) / (1.0 - threshold), value)
48+
49+
50+
def _scale_asym(value: float, negative_scale: float, positive_scale: float) -> float:
51+
return value * (positive_scale if value >= 0.0 else negative_scale)
52+
53+
54+
class _LowPassFilter:
55+
def __init__(self, cutoff_hz: float, dt: float) -> None:
56+
omega = cutoff_hz * 2.0 * math.pi
57+
self.alpha = omega * dt / (omega * dt + 1.0)
58+
self.value: float | None = None
59+
60+
def update(self, value: float) -> float:
61+
self.value = value if self.value is None else (1.0 - self.alpha) * self.value + self.alpha * value
62+
return self.value
63+
64+
def reset(self) -> None:
65+
self.value = None
66+
67+
68+
class RateLimitedValue:
69+
"""Limit the rate of change after accepting the initial target."""
70+
71+
def __init__(self, rate_limit: float, dt: float) -> None:
72+
self.rate_limit = rate_limit
73+
self.dt = dt
74+
self.value: float = 0.0
75+
self._initialized = False
76+
77+
def update(self, target: float) -> float:
78+
if not self._initialized:
79+
self._initialized = True
80+
self.value = target
81+
else:
82+
max_delta = self.rate_limit * self.dt
83+
delta = max(-max_delta, min(target - self.value, max_delta))
84+
self.value += delta
85+
return self.value
86+
87+
def reset(self) -> None:
88+
self.value = 0.0
89+
self._initialized = False
5290

5391

5492
@dataclasses.dataclass
@@ -133,6 +171,7 @@ def __init__(
133171
num_worlds: int = 1,
134172
device: str = "cuda:0",
135173
config: JoystickConfig | None = None,
174+
integrate_path: bool = True,
136175
) -> None:
137176
cfg = config or JoystickConfig()
138177
self._cfg = cfg
@@ -152,9 +191,18 @@ def __init__(
152191
# Turbo ramp (rate-limited 0→1 blend)
153192
self._turbo = RateLimitedValue(cfg.turbo_rate, dt)
154193

155-
# Path state (per-world)
156-
self.path_heading = torch.zeros(num_worlds, 1, device=device)
157-
self.path_position = torch.zeros(num_worlds, 2, device=device)
194+
# Torch is needed only by the optional legacy path integrator. DR Legs
195+
# maintains its path in Warp and disables this feature.
196+
self._integrate_path = integrate_path
197+
if integrate_path:
198+
import torch
199+
200+
self.path_heading = torch.zeros(num_worlds, 1, device=device)
201+
self.path_position = torch.zeros(num_worlds, 2, device=device)
202+
self._cmd_vel_buf = torch.zeros(1, 2, device=device)
203+
else:
204+
self.path_heading = None
205+
self.path_position = None
158206

159207
# Command outputs (updated by update())
160208
self.forward_velocity: float = 0.0
@@ -164,9 +212,6 @@ def __init__(
164212
self.head_yaw: float = 0.0
165213
self.turbo_alpha: float = 0.0
166214

167-
# Pre-allocated command velocity buffer (eliminates per-step torch.tensor())
168-
self._cmd_vel_buf = torch.zeros(1, 2, device=device)
169-
170215
# Reset edge-detection state
171216
self._reset_prev = False
172217

@@ -276,6 +321,10 @@ def update(self, root_pos_2d: torch.Tensor | None = None) -> None:
276321

277322
# --- Path integration ---
278323
if root_pos_2d is not None:
324+
if not self._integrate_path:
325+
raise ValueError("Path integration was disabled for this controller")
326+
from newton._src.solvers.kamino.examples.rl.utils import yaw_apply_2d # noqa: PLC0415
327+
279328
dt = self._dt
280329
self._cmd_vel_buf[0, 0] = self.forward_velocity
281330
self._cmd_vel_buf[0, 1] = self.lateral_velocity
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
# SPDX-FileCopyrightText: Copyright (c) 2026 The Newton Developers
2+
# SPDX-License-Identifier: Apache-2.0
3+
4+
"""PyTorch-free ONNX policy inference using Warp-NN."""
5+
6+
from pathlib import Path
7+
8+
import warp as wp
9+
10+
11+
class WarpOnnxPolicy:
12+
"""Evaluate a single-input, single-output ONNX policy on Warp arrays."""
13+
14+
def __init__(self, path: str | Path, device: wp.DeviceLike, batch_size: int) -> None:
15+
try:
16+
from warp_nn.runtime import OnnxRuntime # noqa: PLC0415
17+
except ImportError as exc: # pragma: no cover
18+
raise ImportError(
19+
"Kamino ONNX policy inference requires Warp-NN. Install it with `pip install newton[onnx]`."
20+
) from exc
21+
22+
self.runtime = OnnxRuntime(str(path), device=device, batch_size=batch_size, input_batch_axes=0)
23+
if len(self.runtime.input_names) != 1 or len(self.runtime.output_names) != 1:
24+
raise ValueError(
25+
f"Policy '{path}' must have exactly one input and one output; got "
26+
f"inputs={self.runtime.input_names}, outputs={self.runtime.output_names}"
27+
)
28+
self.input_name = self.runtime.input_names[0]
29+
self.output_name = self.runtime.output_names[0]
30+
31+
def __call__(self, observation: wp.array[wp.float32]) -> wp.array[wp.float32]:
32+
"""Evaluate a contiguous float32 Warp observation batch."""
33+
if not isinstance(observation, wp.array) or observation.dtype != wp.float32:
34+
raise TypeError("Policy observations must be a Warp float32 array")
35+
if not observation.is_contiguous:
36+
raise ValueError("Policy observations must be contiguous")
37+
return self.runtime({self.input_name: observation})[self.output_name]

newton/_src/solvers/kamino/examples/rl/simulation.py

Lines changed: 90 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
import threading
1919

2020
# Thirdparty
21-
import torch # noqa: TID253
2221
import warp as wp
2322

2423
# Kamino
@@ -250,12 +249,14 @@ def __init__(
250249
collapse_fixed_joints: bool = False,
251250
terrain_fn: callable | None = None,
252251
scene_callback: callable | None = None,
252+
use_torch: bool = True,
253253
):
254254
# ----- Device setup -----
255255
self._device = wp.get_device(device)
256256
self._torch_device: str = "cuda" if self._device.is_cuda else "cpu"
257257
self._use_cuda_graph = use_cuda_graph
258258
self._sim_dt = sim_dt
259+
self._use_torch = use_torch
259260

260261
# ----- Video recording -----
261262
self._record_video = record_video
@@ -428,7 +429,7 @@ def apply_shape_colors(shape_colors: dict[int, Color3]):
428429
# ------------------------------------------------------------------
429430

430431
def _make_rl_interface(self):
431-
"""Create zero-copy PyTorch views of simulator state, control and contact arrays."""
432+
"""Create Warp views and, when requested, zero-copy PyTorch views."""
432433
nw = self.sim.model.size.num_worlds
433434
njc = self.sim.model.size.max_of_num_joint_coords
434435
njd = self.sim.model.size.max_of_num_joint_dofs
@@ -439,6 +440,38 @@ def _make_rl_interface(self):
439440
assert self.sim.model.size.sum_of_num_joint_coords == nw * njc
440441
assert self.sim.model.size.sum_of_num_joint_dofs == nw * njd
441442

443+
# Warp state/control views are always available. This lets ONNX/Warp
444+
# examples run without importing PyTorch at all.
445+
self._q_j_wp = self.sim.state.q_j.reshape((nw, njc))
446+
self._dq_j_wp = self.sim.state.dq_j.reshape((nw, njd))
447+
self._q_i_wp = self.sim.state.q_i.reshape((nw, nb))
448+
self._u_i_wp = self.sim.state.u_i.reshape((nw, nb))
449+
self._q_j_ref_wp = self.sim.control.q_j_ref.reshape((nw, njc))
450+
self._dq_j_ref_wp = self.sim.control.dq_j_ref.reshape((nw, njd))
451+
self._tau_j_ref_wp = self.sim.control.tau_j_ref.reshape((nw, njd))
452+
453+
# World mask and reset buffers are native Warp arrays.
454+
self._world_mask_wp = wp.zeros((nw,), dtype=wp.bool, device=self._device)
455+
self._reset_base_q_wp = wp.zeros(nw, dtype=wp.transformf, device=self._device)
456+
self._reset_base_u_wp = wp.zeros(nw, dtype=wp.spatial_vectorf, device=self._device)
457+
self._reset_q_j_wp = wp.zeros(nw * njc, dtype=wp.float32, device=self._device)
458+
self._reset_dq_j_wp = wp.zeros(nw * njd, dtype=wp.float32, device=self._device)
459+
460+
# Contact aggregation is required by the simulator step in either mode.
461+
self._contact_aggregation = ContactAggregation(model=self.sim.model, contacts=self.sim.contacts)
462+
463+
if not self._use_torch:
464+
self._body_pair_contact_flag = None
465+
self._update_q_j = False
466+
self._update_dq_j = False
467+
self._update_base_q = False
468+
self._update_base_u = False
469+
return
470+
471+
# Import lazily: the Warp-only replay path must work without Torch installed.
472+
import torch
473+
474+
self._torch = torch
442475
# State tensors (read-only views into simulator)
443476
# q_j uses generalized coordinates (njc), dq_j uses DOFs (njd)
444477
self._q_j = wp.to_torch(self.sim.state.q_j).reshape(nw, njc)
@@ -452,15 +485,9 @@ def _make_rl_interface(self):
452485
self._dq_j_ref = wp.to_torch(self.sim.control.dq_j_ref).reshape(nw, njd)
453486
self._tau_j_ref = wp.to_torch(self.sim.control.tau_j_ref).reshape(nw, njd)
454487

455-
# World mask for selective resets
456-
self._world_mask_wp = wp.zeros((nw,), dtype=wp.bool, device=self._device)
457488
self._world_mask = wp.to_torch(self._world_mask_wp)
458489

459490
# Reset buffers
460-
self._reset_base_q_wp = wp.zeros(nw, dtype=wp.transformf, device=self._device)
461-
self._reset_base_u_wp = wp.zeros(nw, dtype=wp.spatial_vectorf, device=self._device)
462-
self._reset_q_j_wp = wp.zeros(nw * njc, dtype=wp.float32, device=self._device)
463-
self._reset_dq_j_wp = wp.zeros(nw * njd, dtype=wp.float32, device=self._device)
464491
self._reset_base_q = wp.to_torch(self._reset_base_q_wp).reshape(nw, 7)
465492
self._reset_base_u = wp.to_torch(self._reset_base_u_wp).reshape(nw, 6)
466493
self._reset_q_j = wp.to_torch(self._reset_q_j_wp).reshape(nw, njc)
@@ -472,8 +499,6 @@ def _make_rl_interface(self):
472499
self._update_base_q = False
473500
self._update_base_u = False
474501

475-
# Contact aggregation
476-
self._contact_aggregation = ContactAggregation(model=self.sim.model, contacts=self.sim.contacts)
477502
self._contact_flags = wp.to_torch(self._contact_aggregation.body_contact_flag).reshape(nw, nb)
478503
self._ground_contact_flags = wp.to_torch(self._contact_aggregation.body_static_contact_flag).reshape(nw, nb)
479504
self._net_contact_forces = wp.to_torch(self._contact_aggregation.body_net_force).reshape(nw, nb, 3)
@@ -502,11 +527,11 @@ def _extract_metadata(self):
502527

503528
# Read per-joint metadata from the Kamino model (first world only)
504529
joint_labels = [lbl.rsplit("/", 1)[-1] for lbl in self.sim.model.joints.label[:max_joints]]
505-
joint_num_coords = wp.to_torch(self.sim.model.joints.num_coords)[:max_joints].tolist()
506-
joint_num_dofs = wp.to_torch(self.sim.model.joints.num_dofs)[:max_joints].tolist()
507-
joint_act_type = wp.to_torch(self.sim.model.joints.act_type)[:max_joints].tolist()
508-
joint_q_j_min = wp.to_torch(self.sim.model.joints.q_j_min)
509-
joint_q_j_max = wp.to_torch(self.sim.model.joints.q_j_max)
530+
joint_num_coords = self.sim.model.joints.num_coords.numpy()[:max_joints].tolist()
531+
joint_num_dofs = self.sim.model.joints.num_dofs.numpy()[:max_joints].tolist()
532+
joint_act_type = self.sim.model.joints.act_type.numpy()[:max_joints].tolist()
533+
joint_q_j_min = self.sim.model.joints.q_j_min.numpy()
534+
joint_q_j_max = self.sim.model.joints.q_j_max.numpy()
510535

511536
# Joint names and actuated indices
512537
self._joint_names: list[str] = []
@@ -528,12 +553,15 @@ def _extract_metadata(self):
528553
coord_offset += ncoords
529554
dof_offset += ndofs
530555

531-
self._actuated_coord_indices_tensor = torch.tensor(
532-
self._actuated_coord_indices, device=self._torch_device, dtype=torch.long
533-
)
534-
self._actuated_dof_indices_tensor = torch.tensor(
535-
self._actuated_dof_indices, device=self._torch_device, dtype=torch.long
536-
)
556+
self._actuated_coord_indices_wp = wp.array(self._actuated_coord_indices, dtype=wp.int32, device=self._device)
557+
self._actuated_dof_indices_wp = wp.array(self._actuated_dof_indices, dtype=wp.int32, device=self._device)
558+
if self._use_torch:
559+
self._actuated_coord_indices_tensor = self._torch.tensor(
560+
self._actuated_coord_indices, device=self._torch_device, dtype=self._torch.long
561+
)
562+
self._actuated_dof_indices_tensor = self._torch.tensor(
563+
self._actuated_dof_indices, device=self._torch_device, dtype=self._torch.long
564+
)
537565

538566
msg.info(f"Actuated joints ({self.num_actuated}): {self._actuated_joint_names}")
539567

@@ -589,9 +617,9 @@ def step(self):
589617

590618
def reset(self):
591619
"""Full reset of all worlds to initial state."""
592-
self._world_mask.fill_(1)
620+
self._world_mask_wp.fill_(True)
593621
self._reset_worlds()
594-
self._world_mask.zero_()
622+
self._world_mask_wp.zero_()
595623

596624
def apply_resets(self):
597625
"""Apply pending selective resets staged via :meth:`set_dof` / :meth:`set_root`.
@@ -604,7 +632,7 @@ def apply_resets(self):
604632
wp.capture_launch(self._reset_graph)
605633
else:
606634
self._reset_worlds()
607-
self._world_mask.zero_()
635+
self._world_mask_wp.zero_()
608636
self._update_q_j = False
609637
self._update_dq_j = False
610638
self._update_base_q = False
@@ -853,6 +881,44 @@ def set_root(
853881
# State properties (zero-copy torch views)
854882
# ------------------------------------------------------------------
855883

884+
@property
885+
def q_j_wp(self):
886+
"""Joint positions as a zero-copy Warp array."""
887+
return self._q_j_wp
888+
889+
@property
890+
def dq_j_wp(self):
891+
"""Joint velocities as a zero-copy Warp array."""
892+
return self._dq_j_wp
893+
894+
@property
895+
def q_i_wp(self):
896+
"""Body poses as a zero-copy Warp transform array."""
897+
return self._q_i_wp
898+
899+
@property
900+
def u_i_wp(self):
901+
"""Body twists as a zero-copy Warp spatial-vector array."""
902+
return self._u_i_wp
903+
904+
@property
905+
def q_j_ref_wp(self):
906+
"""Joint position references as a zero-copy Warp array."""
907+
return self._q_j_ref_wp
908+
909+
@property
910+
def dq_j_ref_wp(self):
911+
"""Joint velocity references as a zero-copy Warp array."""
912+
return self._dq_j_ref_wp
913+
914+
@property
915+
def actuated_coord_indices_wp(self):
916+
return self._actuated_coord_indices_wp
917+
918+
@property
919+
def actuated_dof_indices_wp(self):
920+
return self._actuated_dof_indices_wp
921+
856922
@property
857923
def q_j(self) -> torch.Tensor:
858924
"""Joint positions ``(num_worlds, num_joint_coords)``."""

0 commit comments

Comments
 (0)