env-ssl-wrapper 0.4.7__tar.gz → 0.5.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (26) hide show
  1. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/PKG-INFO +3 -1
  2. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/__init__.py +3 -0
  3. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/action_chunk.py +4 -3
  4. env_ssl_wrapper-0.5.1/env_ssl_wrapper/evaluate.py +340 -0
  5. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/__init__.py +4 -0
  6. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/action_transform_wrapper.py +3 -2
  7. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/adapters.py +50 -27
  8. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/auto_batched_wrapper.py +0 -1
  9. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/mocks.py +47 -0
  10. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/spaces.py +1 -1
  11. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/standardize_env_wrapper.py +1 -1
  12. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/tensor_wrapper.py +9 -1
  13. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/vector.py +1 -1
  14. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/pyproject.toml +3 -1
  15. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/.gitignore +0 -0
  16. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/LICENSE +0 -0
  17. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/README.md +0 -0
  18. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/memory_trace.py +0 -0
  19. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/done_tracker_wrapper.py +0 -0
  20. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/episode_padding_wrapper.py +0 -0
  21. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/flatten_obs_wrapper.py +0 -0
  22. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/helpers.py +0 -0
  23. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/image_wrapper.py +0 -0
  24. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/standardize_wrapper.py +0 -0
  25. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/time_limit_wrapper.py +0 -0
  26. {env_ssl_wrapper-0.4.7 → env_ssl_wrapper-0.5.1}/env_ssl_wrapper/standardize/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: env-ssl-wrapper
3
- Version: 0.4.7
3
+ Version: 0.5.1
4
4
  Summary: One torch-native interface for any MDP environment
5
5
  Project-URL: Homepage, https://pypi.org/project/env-ssl-wrapper/
6
6
  Project-URL: Repository, https://codeberg.org/lucidrains/env-ssl-wrapper
@@ -37,6 +37,8 @@ Requires-Python: >=3.10
37
37
  Requires-Dist: discrete-continuous-embed-readout
38
38
  Requires-Dist: einops>=0.8.1
39
39
  Requires-Dist: einx>=0.3.0
40
+ Requires-Dist: imageio
41
+ Requires-Dist: imageio-ffmpeg
40
42
  Requires-Dist: memmap-replay-buffer
41
43
  Requires-Dist: torch-einops-utils>=0.0.29
42
44
  Requires-Dist: torch>=2.5
@@ -6,6 +6,7 @@ from .standardize import *
6
6
 
7
7
  from .memory_trace import MemoryTraceWrapper
8
8
  from .action_chunk import ActionChunkWrapper
9
+ from .evaluate import evaluate_actor, EpisodeStats
9
10
 
10
11
  # Wire backwards-compatibility aliases in sys.modules and module globals
11
12
  # so imports like `from env_ssl_wrapper.done_tracker_wrapper import DoneTrackerWrapper`
@@ -40,6 +41,8 @@ __all__ = [
40
41
  *standardize.__all__,
41
42
  'MemoryTraceWrapper',
42
43
  'ActionChunkWrapper',
44
+ 'evaluate_actor',
45
+ 'EpisodeStats',
43
46
  ]
44
47
 
45
48
  def __getattr__(name):
@@ -12,6 +12,7 @@ from .standardize.helpers import (
12
12
  any_true,
13
13
  dones_of,
14
14
  first_existing,
15
+ get_adapter,
15
16
  get_attr,
16
17
  is_vectorized,
17
18
  to_numpy,
@@ -70,7 +71,7 @@ class ActionChunkWrapper(EnvWrapper):
70
71
  self.action_shape = tuple(get_attr(action_space, 'shape', ()) or ())
71
72
 
72
73
  self.expects_batch = is_vectorized(env)
73
- self.raw_env = not isinstance(env, EnvWrapper)
74
+ self.convert_in = not isinstance(env, EnvWrapper) and not get_adapter(env).torch_native
74
75
 
75
76
  @property
76
77
  def chunk_action_shape(self):
@@ -86,9 +87,9 @@ class ActionChunkWrapper(EnvWrapper):
86
87
  if not (is_tensor(actions) or isinstance(actions, np.ndarray)):
87
88
  actions = np.asarray(actions)
88
89
 
89
- # raw simulators only understand numpy - wrapper chains from this package handle torch actions
90
+ # raw simulators only understand numpy — wrapper chains and torch-native sims take torch
90
91
 
91
- if self.raw_env and is_tensor(actions):
92
+ if is_tensor(actions) and self.convert_in:
92
93
  actions = to_numpy(actions)
93
94
 
94
95
  chunk_axis = actions.ndim - 1 - len(self.action_shape)
@@ -0,0 +1,340 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+ from typing import NamedTuple
5
+
6
+ import numpy as np
7
+ import torch
8
+ from torch import nn, is_tensor
9
+ from torch.distributions import Categorical, Distribution
10
+ from torch.utils._pytree import tree_flatten, tree_map
11
+
12
+ from .standardize.helpers import (
13
+ EnvWrapper,
14
+ env_autoresets,
15
+ env_num_envs,
16
+ env_render,
17
+ exists,
18
+ first_existing,
19
+ get_adapter,
20
+ get_attr,
21
+ is_array,
22
+ is_vectorized,
23
+ normalize_reset_out,
24
+ normalize_step_out,
25
+ to_numpy,
26
+ )
27
+
28
+ # results
29
+
30
+ class EpisodeStats(NamedTuple):
31
+ returns: torch.Tensor
32
+ lengths: torch.Tensor
33
+ videos: tuple[str, ...] = ()
34
+
35
+ @property
36
+ def num_episodes(self):
37
+ return self.returns.numel()
38
+
39
+ @property
40
+ def mean(self):
41
+ return self.returns.mean().item()
42
+
43
+ @property
44
+ def std(self):
45
+ return self.returns.std(unbiased = False).item()
46
+
47
+ @property
48
+ def min(self):
49
+ return self.returns.min().item()
50
+
51
+ @property
52
+ def max(self):
53
+ return self.returns.max().item()
54
+
55
+ @property
56
+ def video_path(self):
57
+ return self.videos[0] if self.videos else None
58
+
59
+ def success_rate(self, threshold):
60
+ return (self.returns >= threshold).float().mean().item()
61
+
62
+ def summary(self):
63
+ return dict(episodes = self.num_episodes, mean = self.mean, std = self.std, min = self.min, max = self.max)
64
+
65
+ # actor output -> action
66
+
67
+ def resolve_actor(actor):
68
+ # a callable `.dist` takes precedence, sb3 style
69
+
70
+ module = actor if isinstance(actor, nn.Module) else None
71
+ policy = get_attr(actor, 'dist')
72
+
73
+ if not callable(policy):
74
+ policy = actor
75
+
76
+ assert callable(policy), 'actor must be callable, or expose a callable `.dist`'
77
+
78
+ return policy, module
79
+
80
+ def is_distribution(out):
81
+ if isinstance(out, Distribution):
82
+ return True
83
+
84
+ if is_tensor(out):
85
+ return False
86
+
87
+ return callable(get_attr(out, 'sample')) and (exists(get_attr(out, 'mean')) or exists(get_attr(out, 'probs')))
88
+
89
+ def to_action(env, out, deterministic = True):
90
+ # distributions collapse to their mode, raw floating logits over a discrete space to their argmax
91
+
92
+ if is_distribution(out):
93
+ if not deterministic:
94
+ return out.sample()
95
+
96
+ return out.probs.argmax(dim = -1) if exists(get_attr(out, 'probs')) else out.mean
97
+
98
+ if is_tensor(out) and out.is_floating_point() and out.ndim > 0:
99
+ num_actions = get_attr(get_adapter(env).action_space, 'n')
100
+
101
+ if exists(num_actions) and out.shape[-1] == num_actions:
102
+ return out.argmax(dim = -1) if deterministic else Categorical(logits = out).sample()
103
+
104
+ return out
105
+
106
+ # states and actions
107
+
108
+ def to_actor_obs(obs, device, batched):
109
+ def convert(x):
110
+ if not (is_array(x) or callable(get_attr(x, '__array__'))):
111
+ return x
112
+
113
+ tensor = x if is_tensor(x) else torch.as_tensor(np.asarray(x))
114
+
115
+ if tensor.is_floating_point():
116
+ tensor = tensor.float()
117
+
118
+ tensor = tensor.to(device)
119
+
120
+ return tensor if batched or tensor.ndim == 0 else tensor[None]
121
+
122
+ return tree_map(convert, obs)
123
+
124
+ def to_env_action(env, action):
125
+ if not is_tensor(action):
126
+ action = torch.as_tensor(action)
127
+
128
+ device = get_attr(env, 'device')
129
+ action = action.to(device) if exists(device) else action
130
+
131
+ # raw simulators take unbatched numpy — torch-native sims take torch as-is (zero-copy)
132
+
133
+ if not isinstance(env, EnvWrapper):
134
+ if get_adapter(env).torch_native:
135
+ return action
136
+
137
+ action = to_numpy(action)
138
+
139
+ if action.ndim > 1 and action.shape[0] == 1:
140
+ action = action[0]
141
+
142
+ return action.item() if action.ndim == 0 else action
143
+
144
+ # wrappers take batched torch actions - pad up to the declared rank
145
+
146
+ chunk_shape = get_attr(env, 'chunk_action_shape')
147
+
148
+ if exists(chunk_shape):
149
+ rank = len(chunk_shape) + 1
150
+ else:
151
+ space = first_existing(env, 'single_action_space', 'action_space')
152
+ rank = len(get_attr(space, 'shape', ())) + 1
153
+
154
+ while action.ndim < rank:
155
+ action = action[None]
156
+
157
+ return action
158
+
159
+
160
+ # video
161
+
162
+ def capture_frame(env):
163
+ render = get_attr(env, 'render')
164
+ frame = render() if callable(render) else None
165
+
166
+ if not exists(frame):
167
+ try:
168
+ frame = env_render(env, 256, 256)
169
+ except Exception:
170
+ return None
171
+
172
+ if is_tensor(frame):
173
+ frame = frame.detach().cpu().numpy()
174
+
175
+ frame = np.asarray(frame)
176
+
177
+ if frame.ndim == 4:
178
+ frame = frame[0]
179
+
180
+ if frame.ndim == 3 and frame.shape[0] in (3, 4) and frame.shape[-1] not in (3, 4):
181
+ frame = np.moveaxis(frame, 0, -1)
182
+
183
+ if frame.ndim == 3 and frame.shape[-1] == 4:
184
+ frame = frame[..., :3]
185
+
186
+ if frame.dtype != np.uint8:
187
+ scale = 255. if frame.max() <= 1. else 1.
188
+ frame = np.clip(frame * scale, 0., 255.).astype(np.uint8)
189
+
190
+ return frame
191
+
192
+ def save_video(frames, path, fps = 30):
193
+ import imageio
194
+
195
+ path = Path(path)
196
+ path.parent.mkdir(parents = True, exist_ok = True)
197
+ imageio.mimsave(path, frames, fps = fps, macro_block_size = None)
198
+
199
+ def video_path_at(path, index):
200
+ path = Path(path)
201
+ return str(path if index == 0 else path.with_name(f'{path.stem}_ep{index}{path.suffix}'))
202
+
203
+ # evaluate
204
+
205
+ @torch.no_grad()
206
+ def evaluate_actor(
207
+ actor,
208
+ env,
209
+ episodes = 10,
210
+ max_steps = 1000,
211
+ seed = None,
212
+ goal = None,
213
+ device = None,
214
+ deterministic = True,
215
+ video_path = None,
216
+ video_episodes = 1,
217
+ fps = 30,
218
+ verbose = False
219
+ ):
220
+ # seed fixes the episode seeds, so checkpoints are compared on identical episodes
221
+ # video_path records the first video_episodes episodes
222
+
223
+ policy, module = resolve_actor(actor)
224
+
225
+ if not exists(device):
226
+ params = next(module.parameters(), None) if exists(module) else None
227
+ device = get_attr(params, 'device', torch.device('cpu'))
228
+
229
+ was_training = get_attr(module, 'training')
230
+
231
+ if exists(module):
232
+ module.eval()
233
+
234
+ if exists(goal):
235
+ goal = torch.as_tensor(goal) if not is_tensor(goal) else goal
236
+ goal = goal.to(device)
237
+ goal = goal[None] if goal.ndim == 1 else goal
238
+
239
+ num_envs = env_num_envs(env)
240
+ record = exists(video_path)
241
+
242
+ assert not (record and num_envs > 1), 'video recording requires a single env'
243
+
244
+ obs, info = normalize_reset_out(env.reset(seed = seed) if exists(seed) else env.reset())
245
+ batched = is_vectorized(env) or all(map(is_tensor, tree_flatten(obs)[0]))
246
+
247
+ goal = goal.expand(num_envs, -1) if exists(goal) and goal.shape[0] == 1 and num_envs > 1 else goal
248
+
249
+ returns = torch.zeros(num_envs)
250
+ lengths = torch.zeros(num_envs)
251
+ steps = torch.zeros(num_envs, dtype = torch.long)
252
+
253
+ done_returns, done_lengths, videos, frames = [], [], [], []
254
+
255
+ try:
256
+ while len(done_returns) < episodes:
257
+ if record and len(done_returns) < video_episodes:
258
+ frame = capture_frame(env)
259
+ if exists(frame):
260
+ frames.append(frame)
261
+
262
+ state = to_actor_obs(obs, device, batched)
263
+ out = policy(state, goal) if exists(goal) else policy(state)
264
+ action = to_env_action(env, to_action(env, out, deterministic))
265
+
266
+ obs, reward, terminated, truncated, info = normalize_step_out(env.step(action))
267
+
268
+ # reward chunks collapse to one scalar per env
269
+
270
+ reward = torch.as_tensor(to_numpy(reward), dtype = torch.float32)
271
+
272
+ while reward.ndim > 1:
273
+ reward = reward.sum(dim = -1)
274
+
275
+ if reward.numel() > num_envs:
276
+ reward = reward.reshape(num_envs, -1).sum(dim = -1)
277
+
278
+ returns += reward if reward.numel() == num_envs else reward.expand(num_envs)
279
+
280
+ lengths += info.get('chunk_length', 1) if isinstance(info, dict) else 1
281
+ steps += 1
282
+
283
+ done = to_numpy(terminated | truncated).astype(bool).reshape(-1)
284
+
285
+ if exists(max_steps):
286
+ done |= to_numpy(steps >= max_steps).reshape(-1)
287
+
288
+ for i in np.where(done)[0]:
289
+ if len(done_returns) >= episodes:
290
+ break
291
+
292
+ episode = len(done_returns)
293
+ done_returns.append(returns[i].item())
294
+ done_lengths.append(lengths[i].item())
295
+
296
+ if verbose:
297
+ print(f'episode {episode:3d} | return {done_returns[-1]:8.1f} | length {done_lengths[-1]:7.0f}', flush = True)
298
+
299
+ if record and episode < video_episodes:
300
+ frame = capture_frame(env)
301
+ if exists(frame):
302
+ frames.append(frame)
303
+
304
+ if frames:
305
+ path = video_path_at(video_path, episode)
306
+ save_video(frames, path, fps)
307
+ videos.append(path)
308
+
309
+ returns[i] = 0.
310
+ lengths[i] = 0.
311
+ steps[i] = 0
312
+
313
+ if done.any() and len(done_returns) < episodes:
314
+ if record:
315
+ frames = []
316
+
317
+ # autoresetting envs reset themselves, everything else resets here
318
+
319
+ if not env_autoresets(env) and (num_envs == 1 or done.all() or bool(get_attr(env, 'needs_reset', False))):
320
+ reset_seed = seed + len(done_returns) if exists(seed) and num_envs == 1 else None
321
+ obs, info = normalize_reset_out(env.reset(seed = reset_seed) if exists(reset_seed) else env.reset())
322
+ finally:
323
+ if exists(module) and exists(was_training):
324
+ module.train(was_training)
325
+
326
+ return EpisodeStats(
327
+ returns = torch.tensor(done_returns, dtype = torch.float32),
328
+ lengths = torch.tensor(done_lengths, dtype = torch.float32),
329
+ videos = tuple(videos)
330
+ )
331
+
332
+ if __name__ == '__main__':
333
+ import gymnasium as gym
334
+ from env_ssl_wrapper import StandardizeEnvWrapper
335
+
336
+ env = StandardizeEnvWrapper(gym.make('CartPole-v1'))
337
+
338
+ stats = evaluate_actor(lambda obs: torch.zeros(obs.shape[0], 2), env, episodes = 10, seed = 0, verbose = True)
339
+
340
+ print(stats.summary())
@@ -34,6 +34,7 @@ from .adapters import (
34
34
  BaseEnvAdapter,
35
35
  get_adapter,
36
36
  register_adapter,
37
+ MjlabAdapter,
37
38
  MujocoWarpAdapter,
38
39
  IsaacAdapter,
39
40
  PyBulletAdapter,
@@ -68,6 +69,7 @@ from .mocks import (
68
69
  AutoresetVectorMockEnv,
69
70
  PufferVectorMockEnv,
70
71
  PufferTensorMockEnv,
72
+ MjlabMockEnv,
71
73
  ManiSkillMockEnv,
72
74
  BraxMockEnv,
73
75
  MjxMockEnv,
@@ -121,6 +123,7 @@ __all__ = [
121
123
  'BaseEnvAdapter',
122
124
  'get_adapter',
123
125
  'register_adapter',
126
+ 'MjlabAdapter',
124
127
  'MujocoWarpAdapter',
125
128
  'IsaacAdapter',
126
129
  'PyBulletAdapter',
@@ -149,6 +152,7 @@ __all__ = [
149
152
  'AutoresetVectorMockEnv',
150
153
  'PufferVectorMockEnv',
151
154
  'PufferTensorMockEnv',
155
+ 'MjlabMockEnv',
152
156
  'ManiSkillMockEnv',
153
157
  'BraxMockEnv',
154
158
  'MjxMockEnv',
@@ -129,8 +129,6 @@ class ActionTransformWrapper(EnvWrapper):
129
129
  if not is_float_dtype(t):
130
130
  return t
131
131
 
132
- t = copy_leaf(t)
133
-
134
132
  for ind, transform in enumerate(self.transforms):
135
133
  indices = transform.get('indices', ind if len(self.transforms) > 1 else None)
136
134
 
@@ -141,6 +139,9 @@ class ActionTransformWrapper(EnvWrapper):
141
139
  fn = partial(rescale, from_range = from_range, to_range = to_range)
142
140
 
143
141
  if exists(indices):
142
+ # copy only when writing in place, so the caller's action is never mutated
143
+
144
+ t = copy_leaf(t)
144
145
  t[..., indices] = fn(t[..., indices])
145
146
  else:
146
147
  t = fn(t)
@@ -6,18 +6,20 @@ from .helpers import (
6
6
  exists,
7
7
  first_existing,
8
8
  get_attr,
9
- is_time_step,
10
9
  normalize_reset_out,
11
10
  normalize_step_out,
12
11
  safe_close,
13
12
  truthy_attr,
14
- zero_like,
15
13
  )
16
14
  from .spaces import space_from_action_spec
17
15
 
18
16
  # base adapter
19
17
 
20
18
  class BaseEnvAdapter:
19
+ # torch-native sims consume torch actions directly on device — no numpy roundtrip
20
+
21
+ torch_native = False
22
+
21
23
  @classmethod
22
24
  def matches(cls, env) -> bool:
23
25
  return False
@@ -148,6 +150,10 @@ class WrapperAdapter(BaseEnvAdapter):
148
150
  val = first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')
149
151
  return truthy_attr(val) if exists(val) else self.inner_adapter.autoresets
150
152
 
153
+ @property
154
+ def torch_native(self) -> bool:
155
+ return self.inner_adapter.torch_native
156
+
151
157
  @property
152
158
  def action_space(self):
153
159
  return default(
@@ -194,13 +200,8 @@ class DMControlAdapter(BaseEnvAdapter):
194
200
  def action_space(self):
195
201
  return default(super().action_space, space_from_action_spec(self.env))
196
202
 
197
- @property
198
- def is_vectorized(self) -> bool:
199
- return False
200
-
201
- @property
202
- def autoresets(self) -> bool:
203
- return False
203
+ is_vectorized = False
204
+ autoresets = False
204
205
 
205
206
  # pybullet adapter
206
207
 
@@ -227,17 +228,32 @@ class PyBulletAdapter(BaseEnvAdapter):
227
228
  return
228
229
  super().seed(seed)
229
230
 
230
- @property
231
- def is_vectorized(self) -> bool:
232
- return False
231
+ is_vectorized = False
232
+ autoresets = False
233
+
234
+ # mjlab adapter (manager-based rl envs on mujoco warp)
235
+
236
+ class MjlabAdapter(BaseEnvAdapter):
237
+ torch_native = True
238
+ is_vectorized = True
239
+
240
+ @classmethod
241
+ def matches(cls, env):
242
+ mod = getattr(type(env), '__module__', '')
243
+ name = type(env).__name__
244
+ return 'mjlab' in mod.lower() or name == 'ManagerBasedRlEnv'
233
245
 
234
246
  @property
235
247
  def autoresets(self) -> bool:
236
- return False
248
+ return truthy_attr(get_attr(get_attr(self.env, 'cfg'), 'auto_reset', True))
237
249
 
238
250
  # isaac sim adapter (isaac gym / isaac lab / omniverse)
239
251
 
240
252
  class IsaacAdapter(BaseEnvAdapter):
253
+ torch_native = True
254
+ is_vectorized = True
255
+ autoresets = True
256
+
241
257
  @classmethod
242
258
  def matches(cls, env):
243
259
  mod = getattr(type(env), '__module__', '')
@@ -245,14 +261,6 @@ class IsaacAdapter(BaseEnvAdapter):
245
261
  isaac_kw = ('isaac', 'omni.isaac', 'isaacgym', 'isaaclab')
246
262
  return any(k in mod.lower() for k in isaac_kw) or 'Isaac' in name or exists(get_attr(env, 'sim_device')) or exists(get_attr(env, 'physics_sim_view'))
247
263
 
248
- @property
249
- def is_vectorized(self) -> bool:
250
- return True
251
-
252
- @property
253
- def autoresets(self) -> bool:
254
- return True
255
-
256
264
  # mujoco warp / warp / brax / mjx adapter
257
265
 
258
266
  class MujocoWarpAdapter(BaseEnvAdapter):
@@ -276,16 +284,14 @@ class MujocoWarpAdapter(BaseEnvAdapter):
276
284
  # pufferlib adapter
277
285
 
278
286
  class PufferLibAdapter(BaseEnvAdapter):
287
+ is_vectorized = True
288
+
279
289
  @classmethod
280
290
  def matches(cls, env):
281
291
  mod = getattr(type(env), '__module__', '')
282
292
  name = type(env).__name__
283
293
  return 'Puffer' in name or 'pufferlib' in mod or exists(get_attr(env, 'puffer_env'))
284
294
 
285
- @property
286
- def is_vectorized(self) -> bool:
287
- return True
288
-
289
295
  @property
290
296
  def autoresets(self) -> bool:
291
297
  return truthy_attr(first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode'))
@@ -350,6 +356,18 @@ class GymnasiumAdapter(BaseEnvAdapter):
350
356
  pass
351
357
  return self.num_envs > 1
352
358
 
359
+ def unwrapped_adapter(self):
360
+ # foreign gym wrappers (TimeLimit, RecordEpisodeStatistics, ...) inherit
361
+ # the unwrapped sim's adapter traits
362
+
363
+ inner = get_attr(self.env, 'unwrapped')
364
+ return get_adapter(inner) if exists(inner) and inner is not self.env else None
365
+
366
+ @property
367
+ def torch_native(self) -> bool:
368
+ inner = self.unwrapped_adapter()
369
+ return exists(inner) and inner.torch_native
370
+
353
371
  @property
354
372
  def autoresets(self) -> bool:
355
373
  mode = first_existing(self.env, 'autoreset', 'autoresets', 'autoreset_mode')
@@ -359,9 +377,13 @@ class GymnasiumAdapter(BaseEnvAdapter):
359
377
 
360
378
  try:
361
379
  from gymnasium.vector import VectorEnv
362
- return isinstance(self.env, VectorEnv)
380
+ if isinstance(self.env, VectorEnv):
381
+ return True
363
382
  except ImportError:
364
- return False
383
+ pass
384
+
385
+ inner = self.unwrapped_adapter()
386
+ return exists(inner) and inner.autoresets
365
387
 
366
388
  def seed(self, seed: int):
367
389
  try:
@@ -400,6 +422,7 @@ class DefaultAdapter(BaseEnvAdapter):
400
422
 
401
423
  ADAPTER_REGISTRY: list[type[BaseEnvAdapter]] = [
402
424
  WrapperAdapter,
425
+ MjlabAdapter,
403
426
  MujocoWarpAdapter,
404
427
  IsaacAdapter,
405
428
  PyBulletAdapter,
@@ -19,7 +19,6 @@ from .helpers import (
19
19
  is_tensor,
20
20
  is_vectorized,
21
21
  )
22
- from .spaces import space_from_action_spec
23
22
 
24
23
  # helper functions
25
24
 
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ from types import SimpleNamespace
3
4
  from typing import NamedTuple
4
5
 
5
6
  import numpy as np
@@ -391,6 +392,52 @@ class IsaacLabMockEnv(MockEnv):
391
392
 
392
393
  return self.obs(), torch.randn(self.num_envs), dones, torch.zeros(self.num_envs, dtype = torch.bool), {}
393
394
 
395
+ # mjlab — manager-based RL on mujoco warp: always vectorized, torch-native
396
+ # (step takes torch tensors only), autoresets within step, dict obs of torch tensors
397
+
398
+ class MjlabMockEnv(MockEnv):
399
+ num_envs = 4
400
+ is_vector = True
401
+ is_vector_env = True
402
+
403
+ def __init__(self, num_envs = 4, auto_reset = True, seed = 0):
404
+ self.num_envs = num_envs
405
+ self.cfg = SimpleNamespace(auto_reset = auto_reset)
406
+ super().__init__(seed)
407
+
408
+ def reset(self, *, seed = None, env_ids = None, options = None):
409
+ if seed is not None:
410
+ self.seed(seed)
411
+ else:
412
+ self.reset_state()
413
+ return self.obs(), {}
414
+
415
+ def obs(self):
416
+ return dict(
417
+ policy = torch.randn(self.num_envs, self.obs_dim),
418
+ critic = torch.randn(self.num_envs, self.obs_dim * 2)
419
+ )
420
+
421
+ def step(self, action):
422
+ if not is_tensor(action):
423
+ raise TypeError(f'mjlab envs step on torch tensors, got {type(action).__name__}')
424
+
425
+ self.advance(action)
426
+ self.last_action = action
427
+
428
+ dones = torch.from_numpy(self.is_done())
429
+
430
+ if self.cfg.auto_reset:
431
+ for ind in np.where(dones.numpy())[0]:
432
+ self.t[ind] = 0
433
+ self.state[ind] = 0
434
+
435
+ return self.obs(), torch.ones(self.num_envs), dones, torch.zeros(self.num_envs, dtype = torch.bool), {}
436
+
437
+ # live in the real module path so the adapter recognizes the mock
438
+
439
+ MjlabMockEnv.__module__ = 'mjlab.envs.manager_based_rl_env'
440
+
394
441
  # maniskill — gymnasium-compliant, but always batched even at num_envs = 1:
395
442
  # obs / reward / dones are torch tensors with a leading batch dim, and the
396
443
  # env exposes single_action_space / single_observation_space (unbatched)
@@ -3,7 +3,7 @@ from __future__ import annotations
3
3
  import numpy as np
4
4
  from torch.utils._pytree import tree_map
5
5
 
6
- from .helpers import default, exists, first_existing, get_attr, to_numpy
6
+ from .helpers import default, exists, get_attr, to_numpy
7
7
 
8
8
  # stand-in for environments that do not expose gymnasium spaces
9
9
 
@@ -1,7 +1,7 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import torch
4
- from .helpers import EnvWrapper, exists, default, is_vectorized
4
+ from .helpers import EnvWrapper, exists, is_vectorized
5
5
  from .utils import compose_env
6
6
 
7
7
  class StandardizeEnvWrapper(EnvWrapper):
@@ -12,6 +12,7 @@ from .helpers import (
12
12
  FINAL_OBSERVATION_KEYS,
13
13
  FINAL_OBSERVATION_MASK_KEYS,
14
14
  exists,
15
+ get_adapter,
15
16
  get_attr,
16
17
  is_scalar,
17
18
  unpack_vector_observations,
@@ -96,6 +97,11 @@ class TensorWrapper(EnvWrapper):
96
97
  self.convert_in = convert_in
97
98
  self.convert_out = convert_out
98
99
  self.cast_obs_to_float = cast_obs_to_float
100
+
101
+ # torch-native sims (mjlab, isaac) take torch actions as-is — zero-copy passthrough
102
+
103
+ self.torch_native = get_adapter(env).torch_native
104
+
99
105
  self.cast = partial(numpy_to_torch, device = self.device, cast_obs_to_float = self.cast_obs_to_float)
100
106
 
101
107
  def cast_info(self, info):
@@ -126,7 +132,9 @@ class TensorWrapper(EnvWrapper):
126
132
  return contract(self.cast(t), to_float = to_float)
127
133
 
128
134
  def step(self, action):
129
- action = torch_to_numpy(action) if self.convert_in else action
135
+ if self.convert_in and not self.torch_native:
136
+ action = torch_to_numpy(action)
137
+
130
138
  obs, reward, terminated, truncated, info = self.env.step(action)
131
139
 
132
140
  if not self.convert_out:
@@ -4,7 +4,7 @@ from multiprocessing import get_context
4
4
  import numpy as np
5
5
  import torch
6
6
  from torch import is_tensor
7
- from torch.utils._pytree import tree_flatten, tree_map, tree_structure, tree_unflatten
7
+ from torch.utils._pytree import tree_map
8
8
 
9
9
  from .helpers import (
10
10
  any_true,
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "env-ssl-wrapper"
3
- version = "0.4.7"
3
+ version = "0.5.1"
4
4
  description = "One torch-native interface for any MDP environment"
5
5
  authors = [
6
6
  { name = "Phil Wang", email = "lucidrains@gmail.com" }
@@ -27,6 +27,8 @@ dependencies = [
27
27
  "discrete-continuous-embed-readout",
28
28
  "einx>=0.3.0",
29
29
  "einops>=0.8.1",
30
+ "imageio",
31
+ "imageio-ffmpeg",
30
32
  "memmap-replay-buffer",
31
33
  "torch>=2.5",
32
34
  "torch-einops-utils>=0.0.29",
File without changes