Tavish9 commited on
Commit
39b809b
·
1 Parent(s): 8aa03f0

add docker file and visualized script

Browse files
Dockerfile ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Configure image
2
+ ARG PYTHON_VERSION=3.10
3
+
4
+ FROM python:${PYTHON_VERSION}-slim
5
+ ARG PYTHON_VERSION
6
+ ARG DEBIAN_FRONTEND=noninteractive
7
+
8
+ # Install apt dependencies
9
+ RUN apt-get update && apt-get install -y --no-install-recommends \
10
+ build-essential cmake git wget \
11
+ libglib2.0-0 libgl1-mesa-glx libegl1-mesa ffmpeg \
12
+ && apt-get clean && rm -rf /var/lib/apt/lists/*
13
+
14
+ # Create virtual environment
15
+ RUN ln -s /usr/bin/python${PYTHON_VERSION} /usr/bin/python
16
+ RUN python -m venv /opt/venv
17
+ ENV PATH="/opt/venv/bin:$PATH"
18
+ RUN echo "source /opt/venv/bin/activate" >> /root/.bashrc
19
+
20
+ RUN useradd -m -u 1000 user
21
+
22
+ # Install LeRobot
23
+ RUN git clone https://github.com/huggingface/lerobot.git /lerobot
24
+ WORKDIR /lerobot
25
+ RUN pip install --upgrade --no-cache-dir pip
26
+ RUN pip install --no-cache-dir "." \
27
+ --extra-index-url https://download.pytorch.org/whl/cpu
28
+ RUN pip install --no-cache-dir flask
29
+
30
+ COPY --chown=user . /lerobot
31
+ CMD ["python", "visualize_dataset_html.py", "--host", "0.0.0.0", "--port", "7860"]
templates/visualize_dataset_homepage.html ADDED
@@ -0,0 +1,69 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- copy from https://github.com/huggingface/lerobot/blob/main/lerobot/templates/visualize_dataset_homepage.html -->
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Interactive Video Background Page</title>
8
+ <script src="https://cdn.tailwindcss.com"></script>
9
+ <script defer src="https://cdn.jsdelivr.net/npm/[email protected]/dist/cdn.min.js"></script>
10
+ </head>
11
+ <body class="h-screen overflow-hidden font-mono text-white" x-data="{
12
+ inputValue: '',
13
+ navigateToDataset() {
14
+ const trimmedValue = this.inputValue.trim();
15
+ if (trimmedValue) {
16
+ window.location.href = `/${trimmedValue}`;
17
+ }
18
+ }
19
+ }">
20
+ <div class="fixed inset-0 w-full h-full overflow-hidden">
21
+ <video class="absolute min-w-full min-h-full w-auto h-auto top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2" autoplay muted loop>
22
+ <source src="https://huggingface.co/datasets/cadene/koch_bimanual_folding/resolve/v1.6/videos/observation.images.phone_episode_000037.mp4" type="video/mp4">
23
+ Your browser does not support HTML5 video.
24
+ </video>
25
+ </div>
26
+ <div class="fixed inset-0 bg-black bg-opacity-80"></div>
27
+ <div class="relative z-10 flex flex-col items-center justify-center h-screen">
28
+ <div class="text-center mb-8">
29
+ <h1 class="text-4xl font-bold mb-4">LeRobot Dataset Visualizer</h1>
30
+
31
+ <a href="https://github.com/Tavish9/openx2lerobot" target="_blank" rel="noopener noreferrer" class="underline">create your own datasets</a>
32
+
33
+ <p class="text-xl mb-4"></p>
34
+ <div class="text-left inline-block">
35
+ <h3 class="font-semibold mb-2 mt-4">Example Datasets:</h3>
36
+ <ul class="list-disc list-inside">
37
+ {% for dataset in featured_datasets %}
38
+ <li><a href="/{{ dataset }}" class="text-blue-300 hover:text-blue-100 hover:underline">{{ dataset }}</a></li>
39
+ {% endfor %}
40
+ </ul>
41
+ </div>
42
+ </div>
43
+ <div class="flex w-full max-w-lg px-4 mb-4">
44
+ <input
45
+ type="text"
46
+ x-model="inputValue"
47
+ @keyup.enter="navigateToDataset"
48
+ placeholder="enter dataset id (ex: IPEC-COMMUNITY/jaco_play_lerobot)"
49
+ class="flex-grow px-4 py-2 rounded-l bg-white bg-opacity-20 text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-300"
50
+ >
51
+ <button
52
+ @click="navigateToDataset"
53
+ class="px-4 py-2 bg-blue-500 text-white rounded-r hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-blue-300"
54
+ >
55
+ Go
56
+ </button>
57
+ </div>
58
+
59
+ <details class="mt-4 max-w-full px-4">
60
+ <summary>More example datasets</summary>
61
+ <ul class="list-disc list-inside max-h-28 overflow-y-auto break-all">
62
+ {% for dataset in lerobot_datasets %}
63
+ <li><a href="/{{ dataset }}" class="text-blue-300 hover:text-blue-100 hover:underline">{{ dataset }}</a></li>
64
+ {% endfor %}
65
+ </ul>
66
+ </details>
67
+ </div>
68
+ </body>
69
+ </html>
templates/visualize_dataset_template.html ADDED
@@ -0,0 +1,459 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- copy from https://github.com/huggingface/lerobot/blob/main/lerobot/templates/visualize_dataset_template.html -->
2
+ <!DOCTYPE html>
3
+ <html lang="en">
4
+
5
+ <head>
6
+ <meta charset="UTF-8">
7
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
8
+ <!-- # TODO(rcadene, mishig25): store the js files locally -->
9
+ <script src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.13.5/cdn.min.js" defer></script>
10
+ <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/dygraph.min.js" type="text/javascript"></script>
11
+ <script src="https://cdn.tailwindcss.com"></script>
12
+ <title>{{ dataset_info.repo_id }} episode {{ episode_id }}</title>
13
+ </head>
14
+
15
+ <!-- Use [Alpin.js](https://alpinejs.dev), a lightweight and easy to learn JS framework -->
16
+ <!-- Use [tailwindcss](https://tailwindcss.com/), CSS classes for styling html -->
17
+ <!-- Use [dygraphs](https://dygraphs.com/), a lightweight JS charting library -->
18
+ <body class="flex flex-col md:flex-row h-screen max-h-screen bg-slate-950 text-gray-200" x-data="createAlpineData()" @keydown.window="(e) => {
19
+ // Use the space bar to play and pause, instead of default action (e.g. scrolling)
20
+ const { keyCode, key } = e;
21
+ if (keyCode === 32 || key === ' ') {
22
+ e.preventDefault();
23
+ $refs.btnPause.classList.contains('hidden') ? $refs.btnPlay.click() : $refs.btnPause.click();
24
+ }else if (key === 'ArrowDown' || key === 'ArrowUp'){
25
+ const nextEpisodeId = key === 'ArrowDown' ? {{ episode_id }} + 1 : {{ episode_id }} - 1;
26
+ const lowestEpisodeId = {{ episodes }}.at(0);
27
+ const highestEpisodeId = {{ episodes }}.at(-1);
28
+ if(nextEpisodeId >= lowestEpisodeId && nextEpisodeId <= highestEpisodeId){
29
+ window.location.href = `./episode_${nextEpisodeId}`;
30
+ }
31
+ }
32
+ }">
33
+ <!-- Sidebar -->
34
+ <div x-ref="sidebar" class="bg-slate-900 p-5 break-words overflow-y-auto shrink-0 md:shrink md:w-60 md:max-h-screen">
35
+ <a href="https://github.com/huggingface/lerobot" target="_blank" class="hidden md:block">
36
+ <img src="https://github.com/huggingface/lerobot/raw/main/media/lerobot-logo-thumbnail.png">
37
+ </a>
38
+ <a href="https://huggingface.co/datasets/{{ dataset_info.repo_id }}" target="_blank">
39
+ <h1 class="mb-4 text-xl font-semibold">{{ dataset_info.repo_id }}</h1>
40
+ </a>
41
+
42
+ <ul>
43
+ <li>
44
+ Number of samples/frames: {{ dataset_info.num_samples }}
45
+ </li>
46
+ <li>
47
+ Number of episodes: {{ dataset_info.num_episodes }}
48
+ </li>
49
+ <li>
50
+ Frames per second: {{ dataset_info.fps }}
51
+ </li>
52
+ </ul>
53
+
54
+ <p>Episodes:</p>
55
+ <!-- episodes menu for medium & large screens -->
56
+ <ul class="ml-2 hidden md:block">
57
+ {% for episode in episodes %}
58
+ <li class="font-mono text-sm mt-0.5">
59
+ <a href="episode_{{ episode }}" class="underline {% if episode_id == episode %}font-bold -ml-1{% endif %}">
60
+ Episode {{ episode }}
61
+ </a>
62
+ </li>
63
+ {% endfor %}
64
+ </ul>
65
+
66
+ <!-- episodes menu for small screens -->
67
+ <div class="flex overflow-x-auto md:hidden">
68
+ {% for episode in episodes %}
69
+ <p class="font-mono text-sm mt-0.5 border-r last:border-r-0 px-2 {% if episode_id == episode %}font-bold{% endif %}">
70
+ <a href="episode_{{ episode }}" class="">
71
+ {{ episode }}
72
+ </a>
73
+ </p>
74
+ {% endfor %}
75
+ </div>
76
+
77
+ </div>
78
+
79
+ <!-- Toggle sidebar button -->
80
+ <button class="flex items-center opacity-50 hover:opacity-100 mx-1 hidden md:block"
81
+ @click="() => ($refs.sidebar.classList.toggle('hidden'))" title="Toggle sidebar">
82
+ <div class="bg-slate-500 w-2 h-10 rounded-full"></div>
83
+ </button>
84
+
85
+ <!-- Content -->
86
+ <div class="max-h-screen flex flex-col gap-4 overflow-y-auto md:flex-1">
87
+ <h1 class="text-xl font-bold mt-4 font-mono">
88
+ Episode {{ episode_id }}
89
+ </h1>
90
+
91
+ <!-- Error message -->
92
+ <div class="font-medium text-orange-700 hidden" :class="{ 'hidden': !videoCodecError }">
93
+ <p>Videos could NOT play because <a href="https://en.wikipedia.org/wiki/AV1" target="_blank" class="underline">AV1</a> decoding is not available on your browser.</p>
94
+ <ul class="list-decimal list-inside">
95
+ <li>If iPhone: <span class="italic">It is supported with A17 chip or higher.</span></li>
96
+ <li>If Mac with Safari: <span class="italic">It is supported on most browsers except Safari with M1 chip or higher and on Safari with M3 chip or higher.</span></li>
97
+ <li>Other: <span class="italic">Contact the maintainers on LeRobot discord channel:</span> <a href="https://discord.com/invite/s3KuuzsPFb" target="_blank" class="underline">https://discord.com/invite/s3KuuzsPFb</a></li>
98
+ </ul>
99
+ </div>
100
+
101
+ <!-- Videos -->
102
+ <div class="max-w-32 relative text-sm mb-4 select-none"
103
+ @click.outside="isVideosDropdownOpen = false">
104
+ <div
105
+ @click="isVideosDropdownOpen = !isVideosDropdownOpen"
106
+ class="p-2 border border-slate-500 rounded flex justify-between items-center cursor-pointer"
107
+ >
108
+ <span class="truncate">filter videos</span>
109
+ <div class="transition-transform" :class="{ 'rotate-180': isVideosDropdownOpen }">🔽</div>
110
+ </div>
111
+
112
+ <div x-show="isVideosDropdownOpen"
113
+ class="absolute mt-1 border border-slate-500 rounded shadow-lg z-10">
114
+ <div>
115
+ <template x-for="option in videosKeys" :key="option">
116
+ <div
117
+ @click="videosKeysSelected = videosKeysSelected.includes(option) ? videosKeysSelected.filter(v => v !== option) : [...videosKeysSelected, option]"
118
+ class="p-2 cursor-pointer bg-slate-900"
119
+ :class="{ 'bg-slate-700': videosKeysSelected.includes(option) }"
120
+ x-text="option"
121
+ ></div>
122
+ </template>
123
+ </div>
124
+ </div>
125
+ </div>
126
+
127
+ <div class="flex flex-wrap gap-x-2 gap-y-6">
128
+ {% for video_info in videos_info %}
129
+ <div x-show="!videoCodecError && videosKeysSelected.includes('{{ video_info.filename }}')" class="max-w-96 relative">
130
+ <p class="absolute inset-x-0 -top-4 text-sm text-gray-300 bg-gray-800 px-2 rounded-t-xl truncate">{{ video_info.filename }}</p>
131
+ <video muted loop type="video/mp4" class="object-contain w-full h-full" @canplaythrough="videoCanPlay" @timeupdate="() => {
132
+ if (video.duration) {
133
+ const time = video.currentTime;
134
+ const pc = (100 / video.duration) * time;
135
+ $refs.slider.value = pc;
136
+ dygraphTime = time;
137
+ dygraphIndex = Math.floor(pc * dygraph.numRows() / 100);
138
+ dygraph.setSelection(dygraphIndex, undefined, true, true);
139
+
140
+ $refs.timer.textContent = formatTime(time) + ' / ' + formatTime(video.duration);
141
+
142
+ updateTimeQuery(time.toFixed(2));
143
+ }
144
+ }" @ended="() => {
145
+ $refs.btnPlay.classList.remove('hidden');
146
+ $refs.btnPause.classList.add('hidden');
147
+ }"
148
+ @loadedmetadata="() => ($refs.timer.textContent = formatTime(0) + ' / ' + formatTime(video.duration))">
149
+ <source src="{{ video_info.url }}">
150
+ Your browser does not support the video tag.
151
+ </video>
152
+ </div>
153
+ {% endfor %}
154
+ </div>
155
+
156
+ <!-- Language instruction -->
157
+ {% if videos_info[0].language_instruction %}
158
+ <p class="font-medium mt-2">
159
+ Language Instruction: <span class="italic">{{ videos_info[0].language_instruction }}</span>
160
+ </p>
161
+ {% endif %}
162
+
163
+ <!-- Shortcuts info -->
164
+ <div class="text-sm hidden md:block">
165
+ Hotkeys: <span class="font-mono">Space</span> to pause/unpause, <span class="font-mono">Arrow Down</span> to go to next episode, <span class="font-mono">Arrow Up</span> to go to previous episode.
166
+ </div>
167
+
168
+ <!-- Controllers -->
169
+ <div class="flex gap-1 text-3xl items-center">
170
+ <button x-ref="btnPlay" class="-rotate-90" class="-rotate-90" title="Play. Toggle with Space" @click="() => {
171
+ videos.forEach(video => video.play());
172
+ $refs.btnPlay.classList.toggle('hidden');
173
+ $refs.btnPause.classList.toggle('hidden');
174
+ }">🔽</button>
175
+ <button x-ref="btnPause" class="hidden" title="Pause. Toggle with Space" @click="() => {
176
+ videos.forEach(video => video.pause());
177
+ $refs.btnPlay.classList.toggle('hidden');
178
+ $refs.btnPause.classList.toggle('hidden');
179
+ }">⏸️</button>
180
+ <button title="Jump backward 5 seconds"
181
+ @click="() => (videos.forEach(video => (video.currentTime -= 5)))">⏪</button>
182
+ <button title="Jump forward 5 seconds"
183
+ @click="() => (videos.forEach(video => (video.currentTime += 5)))">⏩</button>
184
+ <button title="Rewind from start"
185
+ @click="() => (videos.forEach(video => (video.currentTime = 0.0)))">↩️</button>
186
+ <input x-ref="slider" max="100" min="0" step="1" type="range" value="0" class="w-80 mx-2" @input="() => {
187
+ const sliderValue = $refs.slider.value;
188
+ videos.forEach(video => {
189
+ const time = (video.duration * sliderValue) / 100;
190
+ video.currentTime = time;
191
+ });
192
+ }" />
193
+ <div x-ref="timer" class="font-mono text-sm border border-slate-500 rounded-lg px-1 py-0.5 shrink-0">0:00 /
194
+ 0:00
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Graph -->
199
+ <div class="flex gap-2 mb-4 flex-wrap">
200
+ <div>
201
+ <div id="graph" @mouseleave="() => {
202
+ dygraph.setSelection(dygraphIndex, undefined, true, true);
203
+ dygraphTime = video.currentTime;
204
+ }">
205
+ </div>
206
+ <p x-ref="graphTimer" class="font-mono ml-14 mt-4"
207
+ x-init="$watch('dygraphTime', value => ($refs.graphTimer.innerText = `Time: ${dygraphTime.toFixed(2)}s`))">
208
+ Time: 0.00s
209
+ </p>
210
+ </div>
211
+
212
+ <table class="text-sm border-collapse border border-slate-700" x-show="currentFrameData">
213
+ <thead>
214
+ <tr>
215
+ <th></th>
216
+ <template x-for="(_, colIndex) in Array.from({length: columns.length}, (_, index) => index)">
217
+ <th class="border border-slate-700">
218
+ <div class="flex gap-x-2 justify-between px-2">
219
+ <input type="checkbox" :checked="isColumnChecked(colIndex)"
220
+ @change="toggleColumn(colIndex)">
221
+ <p x-text="`${columns[colIndex].key}`"></p>
222
+ </div>
223
+ </th>
224
+ </template>
225
+ </tr>
226
+ </thead>
227
+ <tbody>
228
+ <template x-for="(row, rowIndex) in rows">
229
+ <tr class="odd:bg-gray-800 even:bg-gray-900">
230
+ <td class="border border-slate-700">
231
+ <div class="flex gap-x-2 max-w-64 font-semibold px-1 break-all">
232
+ <input type="checkbox" :checked="isRowChecked(rowIndex)"
233
+ @change="toggleRow(rowIndex)">
234
+ <p x-text="`${rowLabels[rowIndex]}`"></p>
235
+ </div>
236
+ </td>
237
+ <template x-for="(cell, colIndex) in row">
238
+ <td x-show="cell" class="border border-slate-700">
239
+ <div class="flex gap-x-2 w-24 justify-between px-2" :class="{ 'hidden': cell.isNull }">
240
+ <input type="checkbox" x-model="cell.checked" @change="updateTableValues()">
241
+ <span x-text="`${!cell.isNull ? cell.value.toFixed(2) : null}`"
242
+ :style="`color: ${cell.color}`"></span>
243
+ </div>
244
+ </td>
245
+ </template>
246
+ </tr>
247
+ </template>
248
+ </tbody>
249
+ </table>
250
+
251
+ <div id="labels" class="hidden">
252
+ </div>
253
+ </div>
254
+ </div>
255
+
256
+ <script>
257
+ const parentOrigin = "https://huggingface.co";
258
+ const searchParams = new URLSearchParams();
259
+ searchParams.set("dataset", "{{ dataset_info.repo_id }}");
260
+ searchParams.set("episode", "{{ episode_id }}");
261
+ window.parent.postMessage({ queryString: searchParams.toString() }, parentOrigin);
262
+ </script>
263
+
264
+ <script>
265
+ function createAlpineData() {
266
+ return {
267
+ // state
268
+ dygraph: null,
269
+ currentFrameData: null,
270
+ checked: [],
271
+ dygraphTime: 0.0,
272
+ dygraphIndex: 0,
273
+ videos: null,
274
+ video: null,
275
+ colors: null,
276
+ nVideos: {{ videos_info | length }},
277
+ nVideoReadyToPlay: 0,
278
+ videoCodecError: false,
279
+ isVideosDropdownOpen: false,
280
+ videosKeys: {{ videos_info | map(attribute='filename') | list | tojson }},
281
+ videosKeysSelected: [],
282
+ columns: {{ columns | tojson }},
283
+ rowLabels: {{ columns | tojson }}.reduce((colA, colB) => colA.value.length > colB.value.length ? colA : colB).value,
284
+
285
+ // alpine initialization
286
+ init() {
287
+ // check if videos can play
288
+ const dummyVideo = document.createElement('video');
289
+ const canPlayVideos = dummyVideo.canPlayType('video/mp4; codecs="av01.0.05M.08"'); // codec source: https://huggingface.co/blog/video-encoding#results
290
+ if(!canPlayVideos){
291
+ this.videoCodecError = true;
292
+ }
293
+ this.videosKeysSelected = this.videosKeys.map(opt => opt)
294
+
295
+ // process CSV data
296
+ const csvDataStr = {{ episode_data_csv_str|tojson|safe }};
297
+ // Create a Blob with the CSV data
298
+ const blob = new Blob([csvDataStr], { type: 'text/csv;charset=utf-8;' });
299
+ // Create a URL for the Blob
300
+ const csvUrl = URL.createObjectURL(blob);
301
+
302
+ // process CSV data
303
+ this.videos = document.querySelectorAll('video');
304
+ this.video = this.videos[0];
305
+ this.dygraph = new Dygraph(document.getElementById("graph"), csvUrl, {
306
+ pixelsPerPoint: 0.01,
307
+ legend: 'always',
308
+ labelsDiv: document.getElementById('labels'),
309
+ labelsKMB: true,
310
+ strokeWidth: 1.5,
311
+ pointClickCallback: (event, point) => {
312
+ this.dygraphTime = point.xval;
313
+ this.updateTableValues(this.dygraphTime);
314
+ },
315
+ highlightCallback: (event, x, points, row, seriesName) => {
316
+ this.dygraphTime = x;
317
+ this.updateTableValues(this.dygraphTime);
318
+ },
319
+ drawCallback: (dygraph, is_initial) => {
320
+ if (is_initial) {
321
+ // dygraph initialization
322
+ this.dygraph.setSelection(this.dygraphIndex, undefined, true, true);
323
+ this.colors = this.dygraph.getColors();
324
+ this.checked = Array(this.colors.length).fill(true);
325
+
326
+ const colors = [];
327
+ let lightness = 30; // const LIGHTNESS = [30, 65, 85]; // state_lightness, action_lightness, pred_action_lightness
328
+ for(const column of this.columns){
329
+ const nValues = column.value.length;
330
+ for (let hue = 0; hue < 360; hue += parseInt(360/nValues)) {
331
+ const color = `hsl(${hue}, 100%, ${lightness}%)`;
332
+ colors.push(color);
333
+ }
334
+ lightness += 35;
335
+ }
336
+
337
+ this.dygraph.updateOptions({ colors });
338
+ this.colors = colors;
339
+
340
+ this.updateTableValues();
341
+
342
+ let url = new URL(window.location.href);
343
+ let params = new URLSearchParams(url.search);
344
+ let time = params.get("t");
345
+ if(time){
346
+ time = parseFloat(time);
347
+ this.videos.forEach(video => (video.currentTime = time));
348
+ }
349
+ }
350
+ },
351
+ });
352
+ },
353
+
354
+ //#region Table Data
355
+
356
+ // turn dygraph's 1D data (at a given time t) to 2D data that whose columns names are defined in this.columnNames.
357
+ // 2d data view is used to create html table element.
358
+ get rows() {
359
+ if (!this.currentFrameData) {
360
+ return [];
361
+ }
362
+ const rows = [];
363
+ const nRows = Math.max(...this.columns.map(column => column.value.length));
364
+ let rowIndex = 0;
365
+ while(rowIndex < nRows){
366
+ const row = [];
367
+ // number of states may NOT match number of actions. In this case, we null-pad the 2D array to make a fully rectangular 2d array
368
+ const nullCell = { isNull: true };
369
+ // row consists of [state value, action value]
370
+ let idx = rowIndex;
371
+ for(const column of this.columns){
372
+ const nColumn = column.value.length;
373
+ row.push(rowIndex < nColumn ? this.currentFrameData[idx] : nullCell);
374
+ idx += nColumn; // because this.currentFrameData = [state0, state1, ..., stateN, action0, action1, ..., actionN]
375
+ }
376
+ rowIndex += 1;
377
+ rows.push(row);
378
+ }
379
+ return rows;
380
+ },
381
+ isRowChecked(rowIndex) {
382
+ return this.rows[rowIndex].every(cell => cell && (cell.isNull || cell.checked));
383
+ },
384
+ isColumnChecked(colIndex) {
385
+ return this.rows.every(row => row[colIndex] && (row[colIndex].isNull || row[colIndex].checked));
386
+ },
387
+ toggleRow(rowIndex) {
388
+ const newState = !this.isRowChecked(rowIndex);
389
+ this.rows[rowIndex].forEach(cell => {
390
+ if (cell && !cell.isNull) cell.checked = newState;
391
+ });
392
+ this.updateTableValues();
393
+ },
394
+ toggleColumn(colIndex) {
395
+ const newState = !this.isColumnChecked(colIndex);
396
+ this.rows.forEach(row => {
397
+ if (row[colIndex] && !row[colIndex].isNull) row[colIndex].checked = newState;
398
+ });
399
+ this.updateTableValues();
400
+ },
401
+
402
+ // given time t, update the values in the html table with "data[t]"
403
+ updateTableValues(time) {
404
+ if (!this.colors) {
405
+ return;
406
+ }
407
+ let pc = (100 / this.video.duration) * (time === undefined ? this.video.currentTime : time);
408
+ if (isNaN(pc)) pc = 0;
409
+ const index = Math.floor(pc * this.dygraph.numRows() / 100);
410
+ // slice(1) to remove the timestamp point that we do not need
411
+ const labels = this.dygraph.getLabels().slice(1);
412
+ const values = this.dygraph.rawData_[index].slice(1);
413
+ const checkedNew = this.currentFrameData ? this.currentFrameData.map(cell => cell.checked) : Array(
414
+ this.colors.length).fill(true);
415
+ this.currentFrameData = labels.map((label, idx) => ({
416
+ label,
417
+ value: values[idx],
418
+ color: this.colors[idx],
419
+ checked: checkedNew[idx],
420
+ }));
421
+ const shouldUpdateVisibility = !this.checked.every((value, index) => value === checkedNew[index]);
422
+ if (shouldUpdateVisibility) {
423
+ this.checked = checkedNew;
424
+ this.dygraph.setVisibility(this.checked);
425
+ }
426
+ },
427
+
428
+ //#endregion
429
+
430
+ updateTimeQuery(time) {
431
+ let url = new URL(window.location.href);
432
+ let params = new URLSearchParams(url.search);
433
+ params.set("t", time);
434
+ url.search = params.toString();
435
+ window.history.replaceState({}, '', url.toString());
436
+ },
437
+
438
+ formatTime(time) {
439
+ var hours = Math.floor(time / 3600);
440
+ var minutes = Math.floor((time % 3600) / 60);
441
+ var seconds = Math.floor(time % 60);
442
+ return (hours > 0 ? hours + ':' : '') + (minutes < 10 ? '0' + minutes : minutes) + ':' + (seconds <
443
+ 10 ?
444
+ '0' + seconds : seconds);
445
+ },
446
+
447
+ videoCanPlay() {
448
+ this.nVideoReadyToPlay += 1;
449
+ if(this.nVideoReadyToPlay == this.nVideos) {
450
+ // start autoplay all videos in sync
451
+ this.$refs.btnPlay.click();
452
+ }
453
+ }
454
+ };
455
+ }
456
+ </script>
457
+ </body>
458
+
459
+ </html>
visualize_dataset_html.py ADDED
@@ -0,0 +1,453 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+
3
+ # Copyright 2024 The HuggingFace Inc. team. All rights reserved.
4
+ #
5
+ # Licensed under the Apache License, Version 2.0 (the "License");
6
+ # you may not use this file except in compliance with the License.
7
+ # You may obtain a copy of the License at
8
+ #
9
+ # http://www.apache.org/licenses/LICENSE-2.0
10
+ #
11
+ # Unless required by applicable law or agreed to in writing, software
12
+ # distributed under the License is distributed on an "AS IS" BASIS,
13
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14
+ # See the License for the specific language governing permissions and
15
+ # limitations under the License.
16
+ """ Visualize data of **all** frames of any episode of a dataset of type LeRobotDataset.
17
+ copy from https://github.com/huggingface/lerobot/blob/main/lerobot/scripts/visualize_dataset_html.py
18
+
19
+ Note: The last frame of the episode doesnt always correspond to a final state.
20
+ That's because our datasets are composed of transition from state to state up to
21
+ the antepenultimate state associated to the ultimate action to arrive in the final state.
22
+ However, there might not be a transition from a final state to another state.
23
+
24
+ Note: This script aims to visualize the data used to train the neural networks.
25
+ ~What you see is what you get~. When visualizing image modality, it is often expected to observe
26
+ lossly compression artifacts since these images have been decoded from compressed mp4 videos to
27
+ save disk space. The compression factor applied has been tuned to not affect success rate.
28
+
29
+ Example of usage:
30
+
31
+ - Visualize data stored on a local machine:
32
+ ```bash
33
+ local$ python lerobot/scripts/visualize_dataset_html.py \
34
+ --repo-id lerobot/pusht
35
+
36
+ local$ open http://localhost:9090
37
+ ```
38
+
39
+ - Visualize data stored on a distant machine with a local viewer:
40
+ ```bash
41
+ distant$ python lerobot/scripts/visualize_dataset_html.py \
42
+ --repo-id lerobot/pusht
43
+
44
+ local$ ssh -L 9090:localhost:9090 distant # create a ssh tunnel
45
+ local$ open http://localhost:9090
46
+ ```
47
+
48
+ - Select episodes to visualize:
49
+ ```bash
50
+ python lerobot/scripts/visualize_dataset_html.py \
51
+ --repo-id lerobot/pusht \
52
+ --episodes 7 3 5 1 4
53
+ ```
54
+ """
55
+
56
+ import argparse
57
+ import csv
58
+ import json
59
+ import logging
60
+ import re
61
+ import shutil
62
+ import tempfile
63
+ from io import StringIO
64
+ from pathlib import Path
65
+
66
+ import numpy as np
67
+ import pandas as pd
68
+ import requests
69
+ from flask import Flask, redirect, render_template, request, url_for
70
+ from huggingface_hub import HfApi
71
+ from lerobot.common.datasets.lerobot_dataset import LeRobotDataset
72
+ from lerobot.common.datasets.utils import IterableNamespace
73
+ from lerobot.common.utils.utils import init_logging
74
+
75
+
76
+ def available_datasets():
77
+ api = HfApi()
78
+ datasets = api.list_datasets(author="IPEC-COMMUNITY", tags=["LeRobot"], filter="modality:video")
79
+ datasets = [dataset.id for dataset in datasets]
80
+ return datasets
81
+
82
+
83
+
84
+ def run_server(
85
+ dataset: LeRobotDataset | IterableNamespace | None,
86
+ episodes: list[int] | None,
87
+ host: str,
88
+ port: str,
89
+ static_folder: Path,
90
+ template_folder: Path,
91
+ ):
92
+ app = Flask(__name__, static_folder=static_folder.resolve(), template_folder=template_folder.resolve())
93
+ app.config["SEND_FILE_MAX_AGE_DEFAULT"] = 0 # specifying not to cache
94
+
95
+ @app.route("/")
96
+ def hommepage(dataset=dataset):
97
+ if dataset:
98
+ dataset_namespace, dataset_name = dataset.repo_id.split("/")
99
+ return redirect(
100
+ url_for(
101
+ "show_episode",
102
+ dataset_namespace=dataset_namespace,
103
+ dataset_name=dataset_name,
104
+ episode_id=0,
105
+ )
106
+ )
107
+
108
+ dataset_param, episode_param = None, None
109
+ all_params = request.args
110
+ if "dataset" in all_params:
111
+ dataset_param = all_params["dataset"]
112
+ if "episode" in all_params:
113
+ episode_param = int(all_params["episode"])
114
+
115
+ if dataset_param:
116
+ dataset_namespace, dataset_name = dataset_param.split("/")
117
+ return redirect(
118
+ url_for(
119
+ "show_episode",
120
+ dataset_namespace=dataset_namespace,
121
+ dataset_name=dataset_name,
122
+ episode_id=episode_param if episode_param is not None else 0,
123
+ )
124
+ )
125
+
126
+ featured_datasets = [
127
+ "IPEC-COMMUNITY/roboturk_lerobot",
128
+ "IPEC-COMMUNITY/cmu_play_fusion_lerobot",
129
+ "IPEC-COMMUNITY/fractal20220817_data_lerobot",
130
+ ]
131
+ return render_template(
132
+ "visualize_dataset_homepage.html",
133
+ featured_datasets=featured_datasets,
134
+ lerobot_datasets=available_datasets(),
135
+ )
136
+
137
+ @app.route("/<string:dataset_namespace>/<string:dataset_name>")
138
+ def show_first_episode(dataset_namespace, dataset_name):
139
+ first_episode_id = 0
140
+ return redirect(
141
+ url_for(
142
+ "show_episode",
143
+ dataset_namespace=dataset_namespace,
144
+ dataset_name=dataset_name,
145
+ episode_id=first_episode_id,
146
+ )
147
+ )
148
+
149
+ @app.route("/<string:dataset_namespace>/<string:dataset_name>/episode_<int:episode_id>")
150
+ def show_episode(dataset_namespace, dataset_name, episode_id, dataset=dataset, episodes=episodes):
151
+ repo_id = f"{dataset_namespace}/{dataset_name}"
152
+ try:
153
+ if dataset is None:
154
+ dataset = get_dataset_info(repo_id)
155
+ except FileNotFoundError:
156
+ return (
157
+ "Make sure to convert your LeRobotDataset to v2 & above. See how to convert your dataset at https://github.com/huggingface/lerobot/pull/461",
158
+ 400,
159
+ )
160
+ dataset_version = dataset.meta._version if isinstance(dataset, LeRobotDataset) else dataset.codebase_version
161
+ match = re.search(r"v(\d+)\.", dataset_version)
162
+ if match:
163
+ major_version = int(match.group(1))
164
+ if major_version < 2:
165
+ return "Make sure to convert your LeRobotDataset to v2 & above."
166
+
167
+ episode_data_csv_str, columns = get_episode_data(dataset, episode_id)
168
+ dataset_info = {
169
+ "repo_id": f"{dataset_namespace}/{dataset_name}",
170
+ "num_samples": dataset.num_frames if isinstance(dataset, LeRobotDataset) else dataset.total_frames,
171
+ "num_episodes": dataset.num_episodes if isinstance(dataset, LeRobotDataset) else dataset.total_episodes,
172
+ "fps": dataset.fps,
173
+ }
174
+ if isinstance(dataset, LeRobotDataset):
175
+ video_paths = [dataset.meta.get_video_file_path(episode_id, key) for key in dataset.meta.video_keys]
176
+ videos_info = [
177
+ {"url": url_for("static", filename=video_path), "filename": video_path.parent.name}
178
+ for video_path in video_paths
179
+ ]
180
+ tasks = dataset.meta.episodes[episode_id]["tasks"]
181
+ else:
182
+ video_keys = [key for key, ft in dataset.features.items() if ft["dtype"] == "video"]
183
+ videos_info = [
184
+ {
185
+ "url": f"https://huggingface.co/datasets/{repo_id}/resolve/main/"
186
+ + dataset.video_path.format(
187
+ episode_chunk=int(episode_id) // dataset.chunks_size,
188
+ video_key=video_key,
189
+ episode_index=episode_id,
190
+ ),
191
+ "filename": video_key,
192
+ }
193
+ for video_key in video_keys
194
+ ]
195
+
196
+ response = requests.get(f"https://huggingface.co/datasets/{repo_id}/resolve/main/meta/episodes.jsonl")
197
+ response.raise_for_status()
198
+ # Split into lines and parse each line as JSON
199
+ tasks_jsonl = [json.loads(line) for line in response.text.splitlines() if line.strip()]
200
+
201
+ filtered_tasks_jsonl = [row for row in tasks_jsonl if row["episode_index"] == episode_id]
202
+ tasks = filtered_tasks_jsonl[0]["tasks"]
203
+
204
+ videos_info[0]["language_instruction"] = tasks
205
+
206
+ if episodes is None:
207
+ episodes = list(
208
+ range(dataset.num_episodes if isinstance(dataset, LeRobotDataset) else dataset.total_episodes)
209
+ )
210
+
211
+ return render_template(
212
+ "visualize_dataset_template.html",
213
+ episode_id=episode_id,
214
+ episodes=episodes,
215
+ dataset_info=dataset_info,
216
+ videos_info=videos_info,
217
+ episode_data_csv_str=episode_data_csv_str,
218
+ columns=columns,
219
+ )
220
+
221
+ app.run(host=host, port=port)
222
+
223
+
224
+ def get_ep_csv_fname(episode_id: int):
225
+ ep_csv_fname = f"episode_{episode_id}.csv"
226
+ return ep_csv_fname
227
+
228
+
229
+ def get_episode_data(dataset: LeRobotDataset | IterableNamespace, episode_index):
230
+ """Get a csv str containing timeseries data of an episode (e.g. state and action).
231
+ This file will be loaded by Dygraph javascript to plot data in real time."""
232
+ columns = []
233
+
234
+ selected_columns = [col for col, ft in dataset.features.items() if ft["dtype"] == "float32"]
235
+ selected_columns.remove("timestamp")
236
+
237
+ # init header of csv with state and action names
238
+ header = ["timestamp"]
239
+
240
+ for column_name in selected_columns:
241
+ dim_state = (
242
+ dataset.meta.shapes[column_name][0]
243
+ if isinstance(dataset, LeRobotDataset)
244
+ else dataset.features[column_name].shape[0]
245
+ )
246
+ header += [f"{column_name}_{i}" for i in range(dim_state)]
247
+
248
+ if "names" in dataset.features[column_name] and dataset.features[column_name]["names"]:
249
+ column_names = dataset.features[column_name]["names"]
250
+ while not isinstance(column_names, list):
251
+ column_names = list(column_names.values())[0]
252
+ else:
253
+ column_names = [f"motor_{i}" for i in range(dim_state)]
254
+ columns.append({"key": column_name, "value": column_names})
255
+
256
+ selected_columns.insert(0, "timestamp")
257
+
258
+ if isinstance(dataset, LeRobotDataset):
259
+ from_idx = dataset.episode_data_index["from"][episode_index]
260
+ to_idx = dataset.episode_data_index["to"][episode_index]
261
+ data = dataset.hf_dataset.select(range(from_idx, to_idx)).select_columns(selected_columns).with_format("pandas")
262
+ else:
263
+ repo_id = dataset.repo_id
264
+
265
+ url = f"https://huggingface.co/datasets/{repo_id}/resolve/main/" + dataset.data_path.format(
266
+ episode_chunk=int(episode_index) // dataset.chunks_size, episode_index=episode_index
267
+ )
268
+ df = pd.read_parquet(url)
269
+ data = df[selected_columns] # Select specific columns
270
+
271
+ rows = np.hstack(
272
+ (
273
+ np.expand_dims(data["timestamp"], axis=1),
274
+ *[np.vstack(data[col]) for col in selected_columns[1:]],
275
+ )
276
+ ).tolist()
277
+
278
+ # Convert data to CSV string
279
+ csv_buffer = StringIO()
280
+ csv_writer = csv.writer(csv_buffer)
281
+ # Write header
282
+ csv_writer.writerow(header)
283
+ # Write data rows
284
+ csv_writer.writerows(rows)
285
+ csv_string = csv_buffer.getvalue()
286
+
287
+ return csv_string, columns
288
+
289
+
290
+ def get_episode_video_paths(dataset: LeRobotDataset, ep_index: int) -> list[str]:
291
+ # get first frame of episode (hack to get video_path of the episode)
292
+ first_frame_idx = dataset.episode_data_index["from"][ep_index].item()
293
+ return [dataset.hf_dataset.select_columns(key)[first_frame_idx][key]["path"] for key in dataset.meta.video_keys]
294
+
295
+
296
+ def get_episode_language_instruction(dataset: LeRobotDataset, ep_index: int) -> list[str]:
297
+ # check if the dataset has language instructions
298
+ if "language_instruction" not in dataset.features:
299
+ return None
300
+
301
+ # get first frame index
302
+ first_frame_idx = dataset.episode_data_index["from"][ep_index].item()
303
+
304
+ language_instruction = dataset.hf_dataset[first_frame_idx]["language_instruction"]
305
+ # TODO (michel-aractingi) hack to get the sentence, some strings in openx are badly stored
306
+ # with the tf.tensor appearing in the string
307
+ return language_instruction.removeprefix("tf.Tensor(b'").removesuffix("', shape=(), dtype=string)")
308
+
309
+
310
+ def get_dataset_info(repo_id: str) -> IterableNamespace:
311
+ response = requests.get(f"https://huggingface.co/datasets/{repo_id}/resolve/main/meta/info.json")
312
+ response.raise_for_status() # Raises an HTTPError for bad responses
313
+ dataset_info = response.json()
314
+ dataset_info["repo_id"] = repo_id
315
+ return IterableNamespace(dataset_info)
316
+
317
+
318
+ def visualize_dataset_html(
319
+ dataset: LeRobotDataset | None,
320
+ episodes: list[int] | None = None,
321
+ output_dir: Path | None = None,
322
+ serve: bool = True,
323
+ host: str = "127.0.0.1",
324
+ port: int = 9090,
325
+ force_override: bool = False,
326
+ ) -> Path | None:
327
+ init_logging()
328
+
329
+ template_dir = Path(__file__).resolve().parent / "templates"
330
+
331
+ if output_dir is None:
332
+ # Create a temporary directory that will be automatically cleaned up
333
+ output_dir = tempfile.mkdtemp(prefix="lerobot_visualize_dataset_")
334
+
335
+ output_dir = Path(output_dir)
336
+ if output_dir.exists():
337
+ if force_override:
338
+ shutil.rmtree(output_dir)
339
+ else:
340
+ logging.info(f"Output directory already exists. Loading from it: '{output_dir}'")
341
+
342
+ output_dir.mkdir(parents=True, exist_ok=True)
343
+
344
+ static_dir = output_dir / "static"
345
+ static_dir.mkdir(parents=True, exist_ok=True)
346
+
347
+ if dataset is None:
348
+ if serve:
349
+ run_server(
350
+ dataset=None,
351
+ episodes=None,
352
+ host=host,
353
+ port=port,
354
+ static_folder=static_dir,
355
+ template_folder=template_dir,
356
+ )
357
+ else:
358
+ # Create a simlink from the dataset video folder containg mp4 files to the output directory
359
+ # so that the http server can get access to the mp4 files.
360
+ if isinstance(dataset, LeRobotDataset):
361
+ ln_videos_dir = static_dir / "videos"
362
+ if not ln_videos_dir.exists():
363
+ ln_videos_dir.symlink_to((dataset.root / "videos").resolve())
364
+
365
+ if serve:
366
+ run_server(dataset, episodes, host, port, static_dir, template_dir)
367
+
368
+
369
+ def main():
370
+ parser = argparse.ArgumentParser()
371
+
372
+ parser.add_argument(
373
+ "--repo-id",
374
+ type=str,
375
+ default=None,
376
+ help="Name of hugging face repositery containing a LeRobotDataset dataset (e.g. `lerobot/pusht` for https://huggingface.co/datasets/lerobot/pusht).",
377
+ )
378
+ parser.add_argument(
379
+ "--local-files-only",
380
+ type=int,
381
+ default=0,
382
+ help="Use local files only. By default, this script will try to fetch the dataset from the hub if it exists.",
383
+ )
384
+ parser.add_argument(
385
+ "--root",
386
+ type=Path,
387
+ default=None,
388
+ help="Root directory for a dataset stored locally (e.g. `--root data`). By default, the dataset will be loaded from hugging face cache folder, or downloaded from the hub if available.",
389
+ )
390
+ parser.add_argument(
391
+ "--load-from-hf-hub",
392
+ type=int,
393
+ default=0,
394
+ help="Load videos and parquet files from HF Hub rather than local system.",
395
+ )
396
+ parser.add_argument(
397
+ "--episodes",
398
+ type=int,
399
+ nargs="*",
400
+ default=None,
401
+ help="Episode indices to visualize (e.g. `0 1 5 6` to load episodes of index 0, 1, 5 and 6). By default loads all episodes.",
402
+ )
403
+ parser.add_argument(
404
+ "--output-dir",
405
+ type=Path,
406
+ default=None,
407
+ help="Directory path to write html files and kickoff a web server. By default write them to 'outputs/visualize_dataset/REPO_ID'.",
408
+ )
409
+ parser.add_argument(
410
+ "--serve",
411
+ type=int,
412
+ default=1,
413
+ help="Launch web server.",
414
+ )
415
+ parser.add_argument(
416
+ "--host",
417
+ type=str,
418
+ default="127.0.0.1",
419
+ help="Web host used by the http server.",
420
+ )
421
+ parser.add_argument(
422
+ "--port",
423
+ type=int,
424
+ default=9090,
425
+ help="Web port used by the http server.",
426
+ )
427
+ parser.add_argument(
428
+ "--force-override",
429
+ type=int,
430
+ default=0,
431
+ help="Delete the output directory if it exists already.",
432
+ )
433
+
434
+ args = parser.parse_args()
435
+ kwargs = vars(args)
436
+ repo_id = kwargs.pop("repo_id")
437
+ load_from_hf_hub = kwargs.pop("load_from_hf_hub")
438
+ root = kwargs.pop("root")
439
+ local_files_only = kwargs.pop("local_files_only")
440
+
441
+ dataset = None
442
+ if repo_id:
443
+ dataset = (
444
+ LeRobotDataset(repo_id, root=root, local_files_only=local_files_only)
445
+ if not load_from_hf_hub
446
+ else get_dataset_info(repo_id)
447
+ )
448
+
449
+ visualize_dataset_html(dataset, **vars(args))
450
+
451
+
452
+ if __name__ == "__main__":
453
+ main()