memmap-replay-buffer 0.2.2__tar.gz → 0.2.4__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. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/PKG-INFO +46 -5
  2. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/README.md +45 -4
  3. memmap_replay_buffer-0.2.4/memmap_replay_buffer/frontend/index.html +285 -0
  4. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/frontend.py +53 -16
  5. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/replay_buffer.py +34 -11
  6. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/video.py +74 -23
  7. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/pyproject.toml +1 -1
  8. memmap_replay_buffer-0.2.4/tests/test_advantage_filtering.py +143 -0
  9. memmap_replay_buffer-0.2.4/tests/test_episode_collector.py +58 -0
  10. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/tests/test_frontend.py +53 -15
  11. memmap_replay_buffer-0.2.4/tests/test_get_all_data.py +64 -0
  12. memmap_replay_buffer-0.2.4/tests/test_n_step.py +205 -0
  13. memmap_replay_buffer-0.2.4/tests/test_replay_buffer.py +764 -0
  14. memmap_replay_buffer-0.2.4/tests/test_replay_buffer_h5py.py +158 -0
  15. memmap_replay_buffer-0.2.4/tests/test_scalar_flexibility.py +45 -0
  16. memmap_replay_buffer-0.2.4/tests/test_store_episode_parity.py +71 -0
  17. memmap_replay_buffer-0.2.4/tests/test_time_window.py +51 -0
  18. memmap_replay_buffer-0.2.4/tests/test_uint8_storage.py +55 -0
  19. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/tests/test_video.py +75 -0
  20. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend/index.html +0 -87
  21. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/.gitignore +0 -0
  22. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/LICENSE +0 -0
  23. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/__init__.py +0 -0
  24. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/concat_replay_buffer.py +0 -0
  25. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/memmap_replay_buffer/episode_collector.py +0 -0
  26. {memmap_replay_buffer-0.2.2 → memmap_replay_buffer-0.2.4}/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.4
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
@@ -205,26 +205,67 @@ buffer.update(episode_ids, returns = returns_for_ids) # specific episodes
205
205
  buffer.update(0, returns = returns_for_one) # scalar index
206
206
  ```
207
207
 
208
+ ### Computed fields and selective timestep training
209
+
210
+ Derived fields must be declared in `fields` when the buffer is created, then written after collection. Use `timestep_start` to write contiguous time chunks without loading a whole episode into memory. A common self-play recipe is to compute per-timestep advantages, threshold them into a boolean field, and then let the timestep-level dataset fetch only the selected timesteps.
211
+
212
+ ```python
213
+ buffer = ReplayBuffer(
214
+ './replay_data',
215
+ max_episodes = 1000,
216
+ max_timesteps = 500,
217
+ fields = dict(
218
+ state = ('float', (8,)),
219
+ reward = 'float',
220
+ advantage = 'float',
221
+ high_advantage = ('bool', (), False), # declares the derived mask
222
+ )
223
+ )
224
+
225
+ # compute advantages in episode batches
226
+ buffer.update(episode_ids, advantage = advantages)
227
+
228
+ # or write contiguous time chunks for all episodes
229
+ for start in range(0, max_len, 128):
230
+ buffer.update(
231
+ timestep_start = start,
232
+ advantage = advantage_chunk
233
+ )
234
+
235
+ # set the mask from a comparison of the computed field
236
+ buffer.update(high_advantage = advantage > threshold)
237
+
238
+ # only selected timesteps are returned
239
+ dataloader = buffer.dataloader(
240
+ batch_size = 64,
241
+ timestep_level = True,
242
+ filter_fields = dict(high_advantage = True)
243
+ )
244
+ ```
245
+
246
+ `demo_advantage_filtering.py` contains a complete self-play loop that computes advantages in batches, marks extreme positive advantages, and trains only on those timesteps.
247
+
208
248
  ### Pulling everything at once
209
249
 
210
250
  ```python
211
251
  all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
212
252
  ```
213
253
 
214
- ### Rendering an episode to video
254
+ ### Rendering episodes to video
215
255
 
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.]`.
256
+ 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
257
 
218
258
  ```python
219
259
  buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
260
+ buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
220
261
  ```
221
262
 
222
263
  `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
223
264
 
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).
265
+ 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
266
 
226
267
  ```bash
227
- $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
268
+ $ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
228
269
  ```
229
270
 
230
271
  ### Reopening, read-only, and clearing
@@ -155,26 +155,67 @@ buffer.update(episode_ids, returns = returns_for_ids) # specific episodes
155
155
  buffer.update(0, returns = returns_for_one) # scalar index
156
156
  ```
157
157
 
158
+ ### Computed fields and selective timestep training
159
+
160
+ Derived fields must be declared in `fields` when the buffer is created, then written after collection. Use `timestep_start` to write contiguous time chunks without loading a whole episode into memory. A common self-play recipe is to compute per-timestep advantages, threshold them into a boolean field, and then let the timestep-level dataset fetch only the selected timesteps.
161
+
162
+ ```python
163
+ buffer = ReplayBuffer(
164
+ './replay_data',
165
+ max_episodes = 1000,
166
+ max_timesteps = 500,
167
+ fields = dict(
168
+ state = ('float', (8,)),
169
+ reward = 'float',
170
+ advantage = 'float',
171
+ high_advantage = ('bool', (), False), # declares the derived mask
172
+ )
173
+ )
174
+
175
+ # compute advantages in episode batches
176
+ buffer.update(episode_ids, advantage = advantages)
177
+
178
+ # or write contiguous time chunks for all episodes
179
+ for start in range(0, max_len, 128):
180
+ buffer.update(
181
+ timestep_start = start,
182
+ advantage = advantage_chunk
183
+ )
184
+
185
+ # set the mask from a comparison of the computed field
186
+ buffer.update(high_advantage = advantage > threshold)
187
+
188
+ # only selected timesteps are returned
189
+ dataloader = buffer.dataloader(
190
+ batch_size = 64,
191
+ timestep_level = True,
192
+ filter_fields = dict(high_advantage = True)
193
+ )
194
+ ```
195
+
196
+ `demo_advantage_filtering.py` contains a complete self-play loop that computes advantages in batches, marks extreme positive advantages, and trains only on those timesteps.
197
+
158
198
  ### Pulling everything at once
159
199
 
160
200
  ```python
161
201
  all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
162
202
  ```
163
203
 
164
- ### Rendering an episode to video
204
+ ### Rendering episodes to video
165
205
 
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.]`.
206
+ 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
207
 
168
208
  ```python
169
209
  buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
210
+ buffer.save_video('./episodes-0-3-5.mp4', episode_index = [0, 3, 5], fps = 30)
170
211
  ```
171
212
 
172
213
  `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
173
214
 
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).
215
+ 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
216
 
176
217
  ```bash
177
- $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
218
+ $ inspect-replay-buffer ./image_data --episode 0,3,5 --field front,wrist --output replay.mp4 --serve 8000
178
219
  ```
179
220
 
180
221
  ### 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
@@ -987,10 +987,18 @@ class ReplayBufferBase:
987
987
 
988
988
  @can_write
989
989
  @beartype
990
- def update(self, indices: int | list | ndarray | slice | None = None, **data):
990
+ def update(
991
+ self,
992
+ indices: int | list | ndarray | slice | None = None,
993
+ timestep_start: int = 0,
994
+ **data
995
+ ):
991
996
  if len(data) == 0:
992
997
  raise ValueError('No data provided to `update`')
993
998
 
999
+ if timestep_start < 0:
1000
+ raise ValueError(f'timestep_start must be non-negative, got {timestep_start}')
1001
+
994
1002
  indices, scalar_index = expand_indices(indices, self.max_episodes)
995
1003
 
996
1004
  if not exists(indices):
@@ -1028,11 +1036,11 @@ class ReplayBufferBase:
1028
1036
 
1029
1037
  time_dim = value.shape[1]
1030
1038
 
1031
- if time_dim > self.max_timesteps:
1039
+ if timestep_start + time_dim > self.max_timesteps:
1032
1040
  raise ValueError(f'You exceeded the `max_timesteps` ({self.max_timesteps}) set on the replay buffer. Please increase it on init.')
1033
1041
 
1034
1042
  value = np.broadcast_to(value, (len(indices), time_dim, *self.shapes[name]))
1035
- self.data[name][indices, :time_dim] = value[order]
1043
+ self.data[name][indices, timestep_start:timestep_start + time_dim] = value[order]
1036
1044
 
1037
1045
  else:
1038
1046
  value = cast_to_target_shape(value, self.meta_shapes[name], num_leading_dims = 1)
@@ -1235,8 +1243,12 @@ class ReplayBufferBase:
1235
1243
 
1236
1244
  # video
1237
1245
 
1246
+ def image_fields(self):
1247
+ """ all fields that look like images, in field declaration order """
1248
+ return [name for name in self.shapes if len(self.shapes[name]) >= 2]
1249
+
1238
1250
  def default_image_field(self):
1239
- candidates = sorted(name for name in self.fieldnames if len(self.shapes[name]) >= 2)
1251
+ candidates = self.image_fields()
1240
1252
 
1241
1253
  if len(candidates) == 1:
1242
1254
  return candidates[0]
@@ -1246,17 +1258,28 @@ class ReplayBufferBase:
1246
1258
 
1247
1259
  raise ValueError(f'multiple image-like fields {candidates} - pass `field` explicitly')
1248
1260
 
1249
- def episode_frames(self, episode_index: int = 0, field: str | None = None):
1250
- field = default(field, self.default_image_field())
1261
+ def episode_frames(self, episode_index: int | Sequence[int] = 0, field: str | None = None):
1262
+ if not exists(field):
1263
+ field = self.default_image_field()
1251
1264
 
1252
1265
  if field not in self.fieldnames:
1253
1266
  raise ValueError(f'invalid field name `{field}` - must be one of {self.fieldnames}')
1254
1267
 
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')
1268
+ episode_indices = [episode_index] if isinstance(episode_index, (int, np.integer)) else list(episode_index)
1269
+
1270
+ if len(episode_indices) == 0:
1271
+ raise ValueError('at least one episode index is required')
1272
+
1273
+ for index in episode_indices:
1274
+ if not (0 <= index < self.num_episodes):
1275
+ raise ValueError(f'episode_index {index} out of range - buffer has {self.num_episodes} episodes')
1276
+
1277
+ frames = [self.data[field][index, :int(self.episode_lens[index])] for index in episode_indices]
1278
+
1279
+ if len(frames) == 1:
1280
+ return frames[0].copy()
1257
1281
 
1258
- episode_len = int(self.episode_lens[episode_index])
1259
- return self.data[field][episode_index, :episode_len].copy()
1282
+ return np.concatenate(frames, axis = 0)
1260
1283
 
1261
1284
  def save_video(self, path: str | Path, episode_index: int = 0, field: str | None = None, fps: int = 30, **kwargs):
1262
1285
  from memmap_replay_buffer.video import trajectory_to_video