memmap-replay-buffer 0.2.2__tar.gz → 0.2.3__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 (25) hide show
  1. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/PKG-INFO +6 -5
  2. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/README.md +5 -4
  3. memmap_replay_buffer-0.2.3/memmap_replay_buffer/frontend/index.html +285 -0
  4. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/frontend.py +53 -16
  5. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/replay_buffer.py +23 -8
  6. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/video.py +74 -23
  7. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/pyproject.toml +1 -1
  8. memmap_replay_buffer-0.2.3/tests/test_episode_collector.py +58 -0
  9. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/tests/test_frontend.py +53 -15
  10. memmap_replay_buffer-0.2.3/tests/test_get_all_data.py +64 -0
  11. memmap_replay_buffer-0.2.3/tests/test_n_step.py +205 -0
  12. memmap_replay_buffer-0.2.3/tests/test_replay_buffer.py +764 -0
  13. memmap_replay_buffer-0.2.3/tests/test_replay_buffer_h5py.py +158 -0
  14. memmap_replay_buffer-0.2.3/tests/test_scalar_flexibility.py +45 -0
  15. memmap_replay_buffer-0.2.3/tests/test_store_episode_parity.py +71 -0
  16. memmap_replay_buffer-0.2.3/tests/test_time_window.py +51 -0
  17. memmap_replay_buffer-0.2.3/tests/test_uint8_storage.py +55 -0
  18. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/tests/test_video.py +75 -0
  19. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend/index.html +0 -87
  20. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/.gitignore +0 -0
  21. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/LICENSE +0 -0
  22. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/__init__.py +0 -0
  23. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/concat_replay_buffer.py +0 -0
  24. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/episode_collector.py +0 -0
  25. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.3}/memmap_replay_buffer/replay_buffer_h5py.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memmap-replay-buffer
3
- Version: 0.2.2
3
+ Version: 0.2.3
4
4
  Summary: Simple Replay Buffer for RL
5
5
  Project-URL: Homepage, https://pypi.org/project/memmap-replay-buffer/
6
6
  Project-URL: Repository, https://codeberg.org/lucidrains/memmap-replay-buffer
@@ -211,20 +211,21 @@ buffer.update(0, returns = returns_for_one) # scalar index
211
211
  all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
212
212
  ```
213
213
 
214
- ### Rendering an episode to video
214
+ ### Rendering episodes to video
215
215
 
216
- When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`.
216
+ When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`. Pass a list of episodes to concatenate them in time.
217
217
 
218
218
  ```python
219
219
  buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
220
+ buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
220
221
  ```
221
222
 
222
223
  `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
223
224
 
224
- The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
225
+ The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend. `--episode` and `--field` accept comma-delimited lists - with multiple cameras (image-like fields), one video is written per field (`<name>.<field>.<ext>`) and the frontend plays them side by side, synchronized, with a live frame counter to the side. The frontend has frame-level controls: a timeline scrubber, single-frame stepping (buttons, left/right arrows, shift for 10, or mouse wheel over the videos), play/pause, and slowdown playback (down to 0.1x).
225
226
 
226
227
  ```bash
227
- $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
228
+ $ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
228
229
  ```
229
230
 
230
231
  ### Reopening, read-only, and clearing
@@ -161,20 +161,21 @@ buffer.update(0, returns = returns_for_one) # scalar index
161
161
  all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
162
162
  ```
163
163
 
164
- ### Rendering an episode to video
164
+ ### Rendering episodes to video
165
165
 
166
- When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`.
166
+ When a field holds one frame per timestep, `save_video` renders an episode to `.mp4` or `.gif` (needs `pip install memmap-replay-buffer[video]`). The image field is auto-detected, and frames may be `uint8` or float in `[0., 1.]`. Pass a list of episodes to concatenate them in time.
167
167
 
168
168
  ```python
169
169
  buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
170
+ buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
170
171
  ```
171
172
 
172
173
  `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
173
174
 
174
- The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend (video centered, field stats and a live frame counter to the side).
175
+ The same is available from the command line, with `--sheet` for a contact sheet and `--serve` for a minimal local frontend. `--episode` and `--field` accept comma-delimited lists - with multiple cameras (image-like fields), one video is written per field (`<name>.<field>.<ext>`) and the frontend plays them side by side, synchronized, with a live frame counter to the side. The frontend has frame-level controls: a timeline scrubber, single-frame stepping (buttons, left/right arrows, shift for 10, or mouse wheel over the videos), play/pause, and slowdown playback (down to 0.1x).
175
176
 
176
177
  ```bash
177
- $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
178
+ $ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
178
179
  ```
179
180
 
180
181
  ### Reopening, read-only, and clearing
@@ -0,0 +1,285 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1" />
6
+ <title>inspect-replay-buffer</title>
7
+ <style>
8
+ :root { color-scheme: dark; }
9
+ * { box-sizing: border-box; }
10
+ body {
11
+ margin: 0;
12
+ min-height: 100vh;
13
+ display: grid;
14
+ place-items: center;
15
+ background: #0f1012;
16
+ color: #e8e8e8;
17
+ font: 13px/1.5 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
18
+ }
19
+ #stage {
20
+ display: flex;
21
+ flex-direction: column;
22
+ gap: 12px;
23
+ width: min(calc(100vw - 340px), 1200px);
24
+ }
25
+ main {
26
+ display: grid;
27
+ grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
28
+ gap: 12px;
29
+ }
30
+ figure { margin: 0; min-width: 0; }
31
+ figure video { width: 100%; max-height: 74vh; display: block; border-radius: 6px; background: #000; }
32
+ figcaption { margin-top: 6px; color: #8a8f98; font-size: 11px; text-transform: uppercase; letter-spacing: .08em; }
33
+ #controls {
34
+ display: flex;
35
+ align-items: center;
36
+ gap: 10px;
37
+ padding: 8px 12px;
38
+ background: #17181b;
39
+ border: 1px solid #26282c;
40
+ border-radius: 6px;
41
+ }
42
+ #controls button {
43
+ width: 40px;
44
+ padding: 4px 0;
45
+ background: #1f2126;
46
+ color: #e8e8e8;
47
+ border: 1px solid #2e3136;
48
+ border-radius: 4px;
49
+ font: inherit;
50
+ cursor: pointer;
51
+ }
52
+ #controls button:hover { background: #26282c; }
53
+ #timeline { flex: 1; accent-color: #7fb4e6; min-width: 0; }
54
+ #controls .time { color: #8a8f98; white-space: nowrap; }
55
+ #speed {
56
+ background: #1f2126;
57
+ color: #e8e8e8;
58
+ border: 1px solid #2e3136;
59
+ border-radius: 4px;
60
+ padding: 4px;
61
+ font: inherit;
62
+ }
63
+ aside {
64
+ position: fixed;
65
+ top: 50%;
66
+ right: 20px;
67
+ transform: translateY(-50%);
68
+ width: 240px;
69
+ max-height: 88vh;
70
+ overflow: auto;
71
+ padding: 16px;
72
+ background: #17181b;
73
+ border: 1px solid #26282c;
74
+ border-radius: 6px;
75
+ }
76
+ aside h1 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #7fb4e6; }
77
+ aside h2 { margin: 16px 0 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a8f98; }
78
+ dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
79
+ dt { color: #8a8f98; }
80
+ dd { margin: 0; overflow-wrap: anywhere; }
81
+ #extras:empty { display: none; }
82
+ @media (max-width: 1000px) {
83
+ body { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px; }
84
+ #stage { width: 100%; max-width: 640px; }
85
+ aside { position: static; transform: none; width: 100%; max-width: 480px; }
86
+ }
87
+ </style>
88
+ </head>
89
+ <body>
90
+ <div id="stage">
91
+ <main id="replays"></main>
92
+ <footer id="controls">
93
+ <button id="step-back" title="previous frame (left arrow)">&#10216;</button>
94
+ <button id="play-pause" title="play / pause (space)">&#9654;</button>
95
+ <button id="step-forward" title="next frame (right arrow)">&#10217;</button>
96
+ <input id="timeline" type="range" min="0" value="0" step="1" />
97
+ <span id="time" class="time">0 / 0</span>
98
+ <select id="speed">
99
+ <option value="0.1">0.1x</option>
100
+ <option value="0.25">0.25x</option>
101
+ <option value="0.5">0.5x</option>
102
+ <option value="1" selected>1x</option>
103
+ <option value="2">2x</option>
104
+ <option value="4">4x</option>
105
+ </select>
106
+ </footer>
107
+ </div>
108
+ <aside>
109
+ <h1>replay buffer</h1>
110
+ <dl id="stats"></dl>
111
+ <h2>fields</h2>
112
+ <dl id="field-stats"></dl>
113
+ <h2>time</h2>
114
+ <dl>
115
+ <dt>frame</dt><dd id="frame">0</dd>
116
+ </dl>
117
+ <div id="extras"></div>
118
+ </aside>
119
+ <script>
120
+ const stats = __REPLAY_STATS__;
121
+ const fields = stats.fields ?? [{ name: 'video' }];
122
+ const extras = document.getElementById('extras');
123
+
124
+ const statsList = document.getElementById('stats');
125
+ for (const [key, value] of Object.entries(stats)) {
126
+ if (key === 'fields') continue;
127
+
128
+ const dt = document.createElement('dt');
129
+ dt.textContent = key;
130
+ const dd = document.createElement('dd');
131
+ dd.textContent = Array.isArray(value) ? value.join(', ') : value;
132
+ statsList.append(dt, dd);
133
+ }
134
+
135
+ const fieldList = document.getElementById('field-stats');
136
+ for (const field of fields) {
137
+ const dt = document.createElement('dt');
138
+ dt.textContent = field.name;
139
+ const dd = document.createElement('dd');
140
+ dd.textContent = [field.shape, field.dtype].filter(Boolean).join(' ') || 'video';
141
+ fieldList.append(dt, dd);
142
+ }
143
+
144
+ // one video per field, every video follows the first
145
+
146
+ const replays = document.getElementById('replays');
147
+ const single = fields.length === 1;
148
+
149
+ const videos = fields.map((field, index) => {
150
+ const figure = document.createElement('figure');
151
+ const video = document.createElement('video');
152
+
153
+ video.src = single ? '/video' : `/video/${encodeURIComponent(field.name)}`;
154
+ video.controls = false;
155
+ video.autoplay = true;
156
+ video.loop = true;
157
+ video.muted = true;
158
+ video.playsInline = true;
159
+
160
+ const caption = document.createElement('figcaption');
161
+ caption.textContent = field.name;
162
+
163
+ figure.append(video, caption);
164
+ replays.append(figure);
165
+ return video;
166
+ });
167
+
168
+ const [master, ...slaves] = videos;
169
+
170
+ const sync = () => {
171
+ for (const slave of slaves) {
172
+ if (Math.abs(slave.currentTime - master.currentTime) > 0.08) {
173
+ slave.currentTime = master.currentTime;
174
+ }
175
+ }
176
+ };
177
+
178
+ slaves.forEach(slave => slave.addEventListener('loadedmetadata', sync));
179
+
180
+ master.addEventListener('play', () => {
181
+ playPause.textContent = '⏸';
182
+ slaves.forEach(video => video.play());
183
+ });
184
+
185
+ master.addEventListener('pause', () => {
186
+ playPause.textContent = '▶';
187
+ slaves.forEach(video => video.pause());
188
+ });
189
+
190
+ master.addEventListener('ratechange', () => {
191
+ slaves.forEach(video => video.playbackRate = master.playbackRate);
192
+ speed.value = String(master.playbackRate);
193
+ });
194
+
195
+ master.addEventListener('seeking', sync);
196
+ master.addEventListener('seeked', sync);
197
+ master.addEventListener('timeupdate', sync);
198
+
199
+ const tick = () => {
200
+ if (!master.paused) sync();
201
+ requestAnimationFrame(tick);
202
+ };
203
+ requestAnimationFrame(tick);
204
+
205
+ // frame-level controls
206
+
207
+ const playPause = document.getElementById('play-pause');
208
+ const timeline = document.getElementById('timeline');
209
+ const timeLabel = document.getElementById('time');
210
+ const speed = document.getElementById('speed');
211
+ const frameLabel = document.getElementById('frame');
212
+
213
+ const totalFrames = () => stats.frames ?? (Number.isFinite(master.duration) ? Math.round(master.duration * stats.fps) : 0);
214
+ const frameFromTime = (time) => Math.max(0, Math.min(Math.round(time * stats.fps), totalFrames() - 1));
215
+
216
+ const updateTime = () => {
217
+ const frame = frameFromTime(master.currentTime);
218
+ const total = totalFrames();
219
+
220
+ frameLabel.textContent = `${frame} / ${total}`;
221
+ timeLabel.textContent = `${frame} / ${total}`;
222
+ timeline.max = String(Math.max(total - 1, 0));
223
+
224
+ if (!scrubbing) timeline.value = String(frame);
225
+ };
226
+
227
+ let scrubbing = false;
228
+ timeline.addEventListener('pointerdown', () => scrubbing = true);
229
+ window.addEventListener('pointerup', () => { if (scrubbing) { scrubbing = false; updateTime(); } });
230
+
231
+ const seekToFrame = (frame) => {
232
+ master.currentTime = Math.max(0, Math.min(frame, totalFrames() - 1)) / stats.fps;
233
+ sync();
234
+ updateTime();
235
+ };
236
+
237
+ timeline.addEventListener('input', () => {
238
+ master.pause();
239
+ seekToFrame(Number(timeline.value));
240
+ });
241
+
242
+ playPause.addEventListener('click', () => master.paused ? master.play() : master.pause());
243
+ document.getElementById('step-back').addEventListener('click', () => { master.pause(); seekToFrame(frameFromTime(master.currentTime) - 1); });
244
+ document.getElementById('step-forward').addEventListener('click', () => { master.pause(); seekToFrame(frameFromTime(master.currentTime) + 1); });
245
+
246
+ speed.addEventListener('change', () => master.playbackRate = Number(speed.value));
247
+
248
+ // mouse wheel over the videos steps through frames
249
+
250
+ replays.addEventListener('wheel', (event) => {
251
+ event.preventDefault();
252
+ master.pause();
253
+ seekToFrame(frameFromTime(master.currentTime) + Math.sign(event.deltaY));
254
+ }, { passive: false });
255
+
256
+ window.addEventListener('keydown', (event) => {
257
+ if (event.target.matches('input, select, button, textarea')) return;
258
+
259
+ const step = event.shiftKey ? 10 : 1;
260
+
261
+ if (event.key === ' ') {
262
+ event.preventDefault();
263
+ master.paused ? master.play() : master.pause();
264
+ } else if (event.key === 'ArrowLeft') {
265
+ event.preventDefault();
266
+ master.pause();
267
+ seekToFrame(frameFromTime(master.currentTime) - step);
268
+ } else if (event.key === 'ArrowRight') {
269
+ event.preventDefault();
270
+ master.pause();
271
+ seekToFrame(frameFromTime(master.currentTime) + step);
272
+ }
273
+ });
274
+
275
+ master.addEventListener('timeupdate', updateTime);
276
+ master.addEventListener('loadedmetadata', updateTime);
277
+ master.addEventListener('play', updateTime);
278
+ master.addEventListener('pause', updateTime);
279
+ updateTime();
280
+
281
+ // future: sync value predictions / actions / other per-timestep elements to `master.currentTime`
282
+ window.replay = { stats, fields, videos, master, timeline, seekToFrame, extras };
283
+ </script>
284
+ </body>
285
+ </html>
@@ -1,17 +1,40 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import json
4
+ import mimetypes
4
5
  import threading
5
6
  import webbrowser
6
7
  from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
8
  from pathlib import Path
8
- from urllib.parse import urlparse
9
+ from urllib.parse import unquote, urlparse
9
10
 
10
11
  # helpers
11
12
 
13
+ def normalize_stats(stats: dict):
14
+ """ fold the legacy single-field stats keys into a `fields` list """
15
+ stats = dict(stats)
16
+
17
+ if 'fields' not in stats and 'field' in stats:
18
+ stats['fields'] = [dict(
19
+ name = stats.pop('field'),
20
+ shape = stats.pop('shape', ''),
21
+ dtype = stats.pop('dtype', '')
22
+ )]
23
+
24
+ return stats
25
+
26
+ def normalize_videos(videos):
27
+ if isinstance(videos, dict):
28
+ return {str(name): Path(path) for name, path in videos.items()}
29
+
30
+ return dict(video = Path(videos))
31
+
32
+ def content_type(path: Path):
33
+ return mimetypes.guess_type(path.name)[0] or 'video/mp4'
34
+
12
35
  def render_page(stats: dict):
13
36
  template_path = Path(__file__).parent / 'frontend' / 'index.html'
14
- return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(stats))
37
+ return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(normalize_stats(stats)))
15
38
 
16
39
  def parse_range(range_header: str | None, size: int):
17
40
  if not range_header or not range_header.startswith('bytes='):
@@ -28,7 +51,7 @@ def parse_range(range_header: str | None, size: int):
28
51
  # server
29
52
 
30
53
  class ReplayBufferHandler(BaseHTTPRequestHandler):
31
- video_path: Path
54
+ video_paths: dict[str, Path]
32
55
  page: str
33
56
 
34
57
  protocol_version = 'HTTP/1.1'
@@ -40,11 +63,25 @@ class ReplayBufferHandler(BaseHTTPRequestHandler):
40
63
  path = urlparse(self.path).path
41
64
 
42
65
  if path == '/':
43
- self.send_page()
44
- elif path == '/video':
45
- self.send_video()
46
- else:
47
- self.send_error(404)
66
+ return self.send_page()
67
+
68
+ video_path = self.video_for_path(path)
69
+
70
+ if video_path is not None:
71
+ return self.send_video(video_path)
72
+
73
+ self.send_error(404)
74
+
75
+ def video_for_path(self, path):
76
+ # `/video` serves the first video for backward compatibility
77
+
78
+ if path == '/video':
79
+ return next(iter(self.video_paths.values()), None)
80
+
81
+ if path.startswith('/video/'):
82
+ return self.video_paths.get(unquote(path[len('/video/'):]))
83
+
84
+ return None
48
85
 
49
86
  def send_page(self):
50
87
  body = self.page.encode()
@@ -55,15 +92,15 @@ class ReplayBufferHandler(BaseHTTPRequestHandler):
55
92
  self.end_headers()
56
93
  self.wfile.write(body)
57
94
 
58
- def send_video(self):
59
- size = self.video_path.stat().st_size
95
+ def send_video(self, video_path):
96
+ size = video_path.stat().st_size
60
97
  byte_range = parse_range(self.headers.get('Range'), size)
61
98
 
62
99
  start, end = byte_range if byte_range else (0, size - 1)
63
100
  is_partial = byte_range is not None
64
101
 
65
102
  self.send_response(206 if is_partial else 200)
66
- self.send_header('Content-Type', 'video/mp4')
103
+ self.send_header('Content-Type', content_type(video_path))
67
104
  self.send_header('Content-Length', str(end - start + 1))
68
105
  self.send_header('Accept-Ranges', 'bytes')
69
106
 
@@ -74,7 +111,7 @@ class ReplayBufferHandler(BaseHTTPRequestHandler):
74
111
 
75
112
  # stream the selected slice, seeking works via repeated range requests
76
113
 
77
- with open(self.video_path, 'rb') as f:
114
+ with open(video_path, 'rb') as f:
78
115
  f.seek(start)
79
116
  remaining = end - start + 1
80
117
 
@@ -87,16 +124,16 @@ class ReplayBufferHandler(BaseHTTPRequestHandler):
87
124
  self.wfile.write(chunk)
88
125
  remaining -= len(chunk)
89
126
 
90
- def make_server(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000):
127
+ def make_server(videos: str | Path | dict[str, Path], stats: dict, host = '127.0.0.1', port = 8000):
91
128
  handler = type('Handler', (ReplayBufferHandler,), dict(
92
- video_path = Path(video_path),
129
+ video_paths = normalize_videos(videos),
93
130
  page = render_page(stats)
94
131
  ))
95
132
 
96
133
  return ThreadingHTTPServer((host, port), handler)
97
134
 
98
- def serve(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000, open_browser = True, block = True):
99
- httpd = make_server(video_path, stats, host, port)
135
+ def serve(videos: str | Path | dict[str, Path], stats: dict, host = '127.0.0.1', port = 8000, open_browser = True, block = True):
136
+ httpd = make_server(videos, stats, host, port)
100
137
  url = f'http://{host}:{httpd.server_address[1]}/'
101
138
 
102
139
  print(f'serving replay at {url} (ctrl-c to stop)')
@@ -11,7 +11,7 @@ import einx
11
11
  import numpy as np
12
12
  import torch
13
13
  from beartype import beartype
14
- from beartype.typing import Any, Literal
14
+ from beartype.typing import Any, Literal, Sequence
15
15
  from einops import rearrange
16
16
  from numpy import ndarray
17
17
  from numpy.lib.format import open_memmap
@@ -1235,8 +1235,12 @@ class ReplayBufferBase:
1235
1235
 
1236
1236
  # video
1237
1237
 
1238
+ def image_fields(self):
1239
+ """ all fields that look like images, in field declaration order """
1240
+ return [name for name in self.shapes if len(self.shapes[name]) >= 2]
1241
+
1238
1242
  def default_image_field(self):
1239
- candidates = sorted(name for name in self.fieldnames if len(self.shapes[name]) >= 2)
1243
+ candidates = self.image_fields()
1240
1244
 
1241
1245
  if len(candidates) == 1:
1242
1246
  return candidates[0]
@@ -1246,17 +1250,28 @@ class ReplayBufferBase:
1246
1250
 
1247
1251
  raise ValueError(f'multiple image-like fields {candidates} - pass `field` explicitly')
1248
1252
 
1249
- def episode_frames(self, episode_index: int = 0, field: str | None = None):
1250
- field = default(field, self.default_image_field())
1253
+ def episode_frames(self, episode_index: int | Sequence[int] = 0, field: str | None = None):
1254
+ if not exists(field):
1255
+ field = self.default_image_field()
1251
1256
 
1252
1257
  if field not in self.fieldnames:
1253
1258
  raise ValueError(f'invalid field name `{field}` - must be one of {self.fieldnames}')
1254
1259
 
1255
- if not (0 <= episode_index < self.num_episodes):
1256
- raise ValueError(f'episode_index {episode_index} out of range - buffer has {self.num_episodes} episodes')
1260
+ episode_indices = [episode_index] if isinstance(episode_index, (int, np.integer)) else list(episode_index)
1261
+
1262
+ if len(episode_indices) == 0:
1263
+ raise ValueError('at least one episode index is required')
1264
+
1265
+ for index in episode_indices:
1266
+ if not (0 <= index < self.num_episodes):
1267
+ raise ValueError(f'episode_index {index} out of range - buffer has {self.num_episodes} episodes')
1268
+
1269
+ frames = [self.data[field][index, :int(self.episode_lens[index])] for index in episode_indices]
1270
+
1271
+ if len(frames) == 1:
1272
+ return frames[0].copy()
1257
1273
 
1258
- episode_len = int(self.episode_lens[episode_index])
1259
- return self.data[field][episode_index, :episode_len].copy()
1274
+ return np.concatenate(frames, axis = 0)
1260
1275
 
1261
1276
  def save_video(self, path: str | Path, episode_index: int = 0, field: str | None = None, fps: int = 30, **kwargs):
1262
1277
  from memmap_replay_buffer.video import trajectory_to_video