memmap-replay-buffer 0.2.1__tar.gz → 0.2.2__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 (32) hide show
  1. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/PKG-INFO +22 -1
  2. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/README.md +16 -0
  3. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/__init__.py +2 -0
  4. memmap_replay_buffer-0.2.2/memmap_replay_buffer/concat_replay_buffer.py +93 -0
  5. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend/index.html +87 -0
  6. memmap_replay_buffer-0.2.2/memmap_replay_buffer/frontend.py +118 -0
  7. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/replay_buffer.py +565 -623
  8. memmap_replay_buffer-0.2.2/memmap_replay_buffer/replay_buffer_h5py.py +233 -0
  9. memmap_replay_buffer-0.2.2/memmap_replay_buffer/video.py +163 -0
  10. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/pyproject.toml +11 -2
  11. memmap_replay_buffer-0.2.2/tests/test_frontend.py +134 -0
  12. memmap_replay_buffer-0.2.2/tests/test_video.py +179 -0
  13. memmap_replay_buffer-0.2.1/memmap_replay_buffer/concat_replay_buffer.py +0 -94
  14. memmap_replay_buffer-0.2.1/memmap_replay_buffer/replay_buffer_h5py.py +0 -969
  15. memmap_replay_buffer-0.2.1/tests/test_e2e.py +0 -376
  16. memmap_replay_buffer-0.2.1/tests/test_edge_cases.py +0 -384
  17. memmap_replay_buffer-0.2.1/tests/test_episode_collector.py +0 -58
  18. memmap_replay_buffer-0.2.1/tests/test_get_all_data.py +0 -64
  19. memmap_replay_buffer-0.2.1/tests/test_lazy_init.py +0 -68
  20. memmap_replay_buffer-0.2.1/tests/test_mixed_storage.py +0 -81
  21. memmap_replay_buffer-0.2.1/tests/test_n_step.py +0 -205
  22. memmap_replay_buffer-0.2.1/tests/test_nstep_timestep_gotcha.py +0 -31
  23. memmap_replay_buffer-0.2.1/tests/test_replay_buffer.py +0 -764
  24. memmap_replay_buffer-0.2.1/tests/test_replay_buffer_h5py.py +0 -158
  25. memmap_replay_buffer-0.2.1/tests/test_rollout_collector.py +0 -187
  26. memmap_replay_buffer-0.2.1/tests/test_scalar_flexibility.py +0 -45
  27. memmap_replay_buffer-0.2.1/tests/test_store_episode_parity.py +0 -71
  28. memmap_replay_buffer-0.2.1/tests/test_time_window.py +0 -51
  29. memmap_replay_buffer-0.2.1/tests/test_uint8_storage.py +0 -55
  30. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/.gitignore +0 -0
  31. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/LICENSE +0 -0
  32. {memmap_replay_buffer-0.2.1 → memmap_replay_buffer-0.2.2}/memmap_replay_buffer/episode_collector.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: memmap-replay-buffer
3
- Version: 0.2.1
3
+ Version: 0.2.2
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
@@ -40,7 +40,12 @@ Requires-Dist: numpy
40
40
  Requires-Dist: torch>=2.4
41
41
  Provides-Extra: test
42
42
  Requires-Dist: h5py; extra == 'test'
43
+ Requires-Dist: imageio; extra == 'test'
44
+ Requires-Dist: imageio-ffmpeg; extra == 'test'
43
45
  Requires-Dist: pytest; extra == 'test'
46
+ Provides-Extra: video
47
+ Requires-Dist: imageio; extra == 'video'
48
+ Requires-Dist: imageio-ffmpeg; extra == 'video'
44
49
  Description-Content-Type: text/markdown
45
50
 
46
51
  ## memmap-replay-buffer
@@ -206,6 +211,22 @@ buffer.update(0, returns = returns_for_one) # scalar index
206
211
  all_data = buffer.get_all_data() # dict of tensors, time-padded to the longest episode
207
212
  ```
208
213
 
214
+ ### Rendering an episode to video
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.]`.
217
+
218
+ ```python
219
+ buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
220
+ ```
221
+
222
+ `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
223
+
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
+
226
+ ```bash
227
+ $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
228
+ ```
229
+
209
230
  ### Reopening, read-only, and clearing
210
231
 
211
232
  `overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
@@ -161,6 +161,22 @@ 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
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.]`.
167
+
168
+ ```python
169
+ buffer.save_video('./episode-0.mp4', episode_index = 0, fps = 30)
170
+ ```
171
+
172
+ `trajectory_to_video(frames, path, fps)` is exported for plain `(time, height, width, channels)` arrays.
173
+
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
+
176
+ ```bash
177
+ $ inspect-replay-buffer ./image_data --episode 0 --output episode-0.mp4 --serve 8000
178
+ ```
179
+
164
180
  ### Reopening, read-only, and clearing
165
181
 
166
182
  `overwrite = False` (or `from_folder`) rehydrates an existing buffer from disk; the stored config is validated against the requested one, so mismatched `fields`/`max_episodes` raise a clear error. `read_only = True` guarantees no files are created or written. `clear()` wipes all episodes.
@@ -12,3 +12,5 @@ except ImportError:
12
12
  pass
13
13
 
14
14
  from memmap_replay_buffer.episode_collector import EpisodeCollector as EpisodeCollector
15
+ from memmap_replay_buffer.video import frames_to_sheet as frames_to_sheet
16
+ from memmap_replay_buffer.video import trajectory_to_video as trajectory_to_video
@@ -0,0 +1,93 @@
1
+ from __future__ import annotations
2
+
3
+ from pathlib import Path
4
+
5
+ import torch
6
+ from torch import Tensor, cat
7
+ from torch.utils.data import ConcatDataset, Dataset
8
+
9
+ from memmap_replay_buffer.replay_buffer import ReplayBuffer, default, exists, pad_right_at_dim
10
+
11
+ class ConcatReplayBuffer:
12
+ def __init__(self, folders: list[str | Path]):
13
+ if len(folders) == 0:
14
+ raise ValueError('ConcatReplayBuffer requires at least one folder')
15
+
16
+ self.buffers = [ReplayBuffer.from_folder(folder, read_only = True) for folder in folders]
17
+
18
+ first_buffer = self.buffers[0]
19
+ self.fieldnames = first_buffer.fieldnames
20
+ self.meta_fieldnames = first_buffer.meta_fieldnames
21
+
22
+ for buffer in self.buffers[1:]:
23
+ if buffer.fieldnames != self.fieldnames:
24
+ raise ValueError(f'All buffers must have the same fieldnames - got {buffer.fieldnames} and {self.fieldnames}')
25
+
26
+ if buffer.meta_fieldnames != self.meta_fieldnames:
27
+ raise ValueError(f'All buffers must have the same meta_fieldnames - got {buffer.meta_fieldnames} and {self.meta_fieldnames}')
28
+
29
+ self.read_only = True
30
+
31
+ @property
32
+ def num_episodes(self):
33
+ return sum(buffer.num_episodes for buffer in self.buffers)
34
+
35
+ @property
36
+ def max_episodes(self):
37
+ return sum(buffer.max_episodes for buffer in self.buffers)
38
+
39
+ @property
40
+ def max_timesteps(self):
41
+ return max(buffer.max_timesteps for buffer in self.buffers)
42
+
43
+ def __len__(self):
44
+ return sum(len(buffer) for buffer in self.buffers)
45
+
46
+ def dataset(self, **kwargs) -> Dataset:
47
+ if len(self) == 0:
48
+ raise ValueError('replay buffer is empty')
49
+
50
+ datasets = [buffer.dataset(**kwargs) for buffer in self.buffers if len(buffer) > 0]
51
+ return ConcatDataset(datasets)
52
+
53
+ def dataloader(self, *args, **kwargs):
54
+ return ReplayBuffer.dataloader(self, *args, **kwargs)
55
+
56
+ def get_all_data(
57
+ self,
58
+ fields: tuple[str, ...] | None = None,
59
+ meta_fields: tuple[str, ...] | None = None,
60
+ device: torch.device | str | None = None
61
+ ) -> dict[str, Tensor]:
62
+
63
+ all_data = [buffer.get_all_data(fields = fields, meta_fields = meta_fields, device = device) for buffer in self.buffers]
64
+ all_data = [data for data in all_data if len(data) > 0]
65
+
66
+ if not all_data:
67
+ return dict()
68
+
69
+ data_fields = self.fieldnames if not exists(fields) and not exists(meta_fields) else default(fields, ())
70
+
71
+ out = dict()
72
+
73
+ for key in all_data[0].keys():
74
+ tensors = [data[key] for data in all_data if key in data]
75
+
76
+ if key in data_fields and tensors[0].ndim > 1:
77
+ # pad trajectories to the longest across buffers
78
+ max_time = max(t.shape[1] for t in tensors)
79
+ tensors = [pad_right_at_dim(t, max_time - t.shape[1], dim = 1) for t in tensors]
80
+
81
+ out[key] = cat(tensors, dim = 0)
82
+
83
+ return out
84
+
85
+ def flush(self):
86
+ pass
87
+
88
+ def _read_only(self, *args, **kwargs):
89
+ raise NotImplementedError('ConcatReplayBuffer is read-only')
90
+
91
+ clear = reset_ = advance_episode = store_batch = store_meta_batch = \
92
+ one_episode = batched_episode = store_datapoint = store_meta_datapoint = \
93
+ store_batch_datapoint = store_batch_meta_datapoint = store = store_episode = update = _read_only
@@ -0,0 +1,87 @@
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
+ video {
20
+ max-width: min(75vw, 1200px);
21
+ max-height: 88vh;
22
+ border-radius: 6px;
23
+ background: #000;
24
+ }
25
+ aside {
26
+ position: fixed;
27
+ top: 50%;
28
+ right: 20px;
29
+ transform: translateY(-50%);
30
+ width: 240px;
31
+ max-height: 88vh;
32
+ overflow: auto;
33
+ padding: 16px;
34
+ background: #17181b;
35
+ border: 1px solid #26282c;
36
+ border-radius: 6px;
37
+ }
38
+ aside h1 { margin: 0 0 12px; font-size: 13px; font-weight: 600; color: #7fb4e6; }
39
+ aside h2 { margin: 16px 0 8px; font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: .08em; color: #8a8f98; }
40
+ dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 4px 12px; margin: 0; }
41
+ dt { color: #8a8f98; }
42
+ dd { margin: 0; overflow-wrap: anywhere; }
43
+ #extras:empty { display: none; }
44
+ @media (max-width: 1000px) {
45
+ body { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px; }
46
+ video { max-width: 100%; }
47
+ aside { position: static; transform: none; width: 100%; max-width: 480px; }
48
+ }
49
+ </style>
50
+ </head>
51
+ <body>
52
+ <video id="replay" src="/video" controls autoplay loop muted></video>
53
+ <aside>
54
+ <h1>replay buffer</h1>
55
+ <dl id="stats"></dl>
56
+ <h2>time</h2>
57
+ <dl>
58
+ <dt>frame</dt><dd id="frame">0</dd>
59
+ </dl>
60
+ <div id="extras"></div>
61
+ </aside>
62
+ <script>
63
+ const stats = __REPLAY_STATS__;
64
+ const video = document.getElementById('replay');
65
+ const extras = document.getElementById('extras');
66
+
67
+ const statsList = document.getElementById('stats');
68
+ for (const [key, value] of Object.entries(stats)) {
69
+ const dt = document.createElement('dt');
70
+ dt.textContent = key;
71
+ const dd = document.createElement('dd');
72
+ dd.textContent = value;
73
+ statsList.append(dt, dd);
74
+ }
75
+
76
+ const frameLabel = document.getElementById('frame');
77
+ const updateTime = () => {
78
+ frameLabel.textContent = `${Math.round(video.currentTime * stats.fps)} / ${stats.frames}`;
79
+ };
80
+ video.addEventListener('timeupdate', updateTime);
81
+ updateTime();
82
+
83
+ // future: sync value predictions / actions / other per-timestep elements to `video.currentTime`
84
+ window.replay = { stats, video, extras };
85
+ </script>
86
+ </body>
87
+ </html>
@@ -0,0 +1,118 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import threading
5
+ import webbrowser
6
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
7
+ from pathlib import Path
8
+ from urllib.parse import urlparse
9
+
10
+ # helpers
11
+
12
+ def render_page(stats: dict):
13
+ template_path = Path(__file__).parent / 'frontend' / 'index.html'
14
+ return template_path.read_text().replace('__REPLAY_STATS__', json.dumps(stats))
15
+
16
+ def parse_range(range_header: str | None, size: int):
17
+ if not range_header or not range_header.startswith('bytes='):
18
+ return None
19
+
20
+ start_str, _, end_str = range_header[len('bytes='):].split(',')[0].partition('-')
21
+
22
+ if not start_str:
23
+ # suffix range, e.g. bytes=-500
24
+ return max(size - int(end_str), 0), size - 1
25
+
26
+ return int(start_str), min(int(end_str), size - 1) if end_str else size - 1
27
+
28
+ # server
29
+
30
+ class ReplayBufferHandler(BaseHTTPRequestHandler):
31
+ video_path: Path
32
+ page: str
33
+
34
+ protocol_version = 'HTTP/1.1'
35
+
36
+ def log_message(self, *args):
37
+ pass
38
+
39
+ def do_GET(self):
40
+ path = urlparse(self.path).path
41
+
42
+ if path == '/':
43
+ self.send_page()
44
+ elif path == '/video':
45
+ self.send_video()
46
+ else:
47
+ self.send_error(404)
48
+
49
+ def send_page(self):
50
+ body = self.page.encode()
51
+
52
+ self.send_response(200)
53
+ self.send_header('Content-Type', 'text/html; charset=utf-8')
54
+ self.send_header('Content-Length', str(len(body)))
55
+ self.end_headers()
56
+ self.wfile.write(body)
57
+
58
+ def send_video(self):
59
+ size = self.video_path.stat().st_size
60
+ byte_range = parse_range(self.headers.get('Range'), size)
61
+
62
+ start, end = byte_range if byte_range else (0, size - 1)
63
+ is_partial = byte_range is not None
64
+
65
+ self.send_response(206 if is_partial else 200)
66
+ self.send_header('Content-Type', 'video/mp4')
67
+ self.send_header('Content-Length', str(end - start + 1))
68
+ self.send_header('Accept-Ranges', 'bytes')
69
+
70
+ if is_partial:
71
+ self.send_header('Content-Range', f'bytes {start}-{end}/{size}')
72
+
73
+ self.end_headers()
74
+
75
+ # stream the selected slice, seeking works via repeated range requests
76
+
77
+ with open(self.video_path, 'rb') as f:
78
+ f.seek(start)
79
+ remaining = end - start + 1
80
+
81
+ while remaining > 0:
82
+ chunk = f.read(min(remaining, 1 << 20))
83
+
84
+ if not chunk:
85
+ break
86
+
87
+ self.wfile.write(chunk)
88
+ remaining -= len(chunk)
89
+
90
+ def make_server(video_path: str | Path, stats: dict, host = '127.0.0.1', port = 8000):
91
+ handler = type('Handler', (ReplayBufferHandler,), dict(
92
+ video_path = Path(video_path),
93
+ page = render_page(stats)
94
+ ))
95
+
96
+ return ThreadingHTTPServer((host, port), handler)
97
+
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)
100
+ url = f'http://{host}:{httpd.server_address[1]}/'
101
+
102
+ print(f'serving replay at {url} (ctrl-c to stop)')
103
+
104
+ if open_browser:
105
+ threading.Thread(target = webbrowser.open, args = (url,), daemon = True).start()
106
+
107
+ if not block:
108
+ threading.Thread(target = httpd.serve_forever, daemon = True).start()
109
+ return httpd, url
110
+
111
+ try:
112
+ httpd.serve_forever()
113
+ except KeyboardInterrupt:
114
+ pass
115
+ finally:
116
+ httpd.server_close()
117
+
118
+ return httpd, url