File size: 23,186 Bytes
39b809b
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
<!-- copy from https://github.com/huggingface/lerobot/blob/main/lerobot/templates/visualize_dataset_template.html -->
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <!-- # TODO(rcadene, mishig25): store the js files locally -->
    <script src="https://cdnjs.cloudflare.com/ajax/libs/alpinejs/3.13.5/cdn.min.js" defer></script>
    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/dygraph.min.js" type="text/javascript"></script>
    <script src="https://cdn.tailwindcss.com"></script>
    <title>{{ dataset_info.repo_id }} episode {{ episode_id }}</title>
</head>

<!-- Use [Alpin.js](https://alpinejs.dev), a lightweight and easy to learn JS framework -->
<!-- Use [tailwindcss](https://tailwindcss.com/), CSS classes for styling html -->
<!-- Use [dygraphs](https://dygraphs.com/), a lightweight JS charting library -->
<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) => {
    // Use the space bar to play and pause, instead of default action (e.g. scrolling)
    const { keyCode, key } = e;
    if (keyCode === 32 || key === ' ') {
        e.preventDefault();
        $refs.btnPause.classList.contains('hidden') ? $refs.btnPlay.click() : $refs.btnPause.click();
    }else if (key === 'ArrowDown' || key === 'ArrowUp'){
        const nextEpisodeId = key === 'ArrowDown' ? {{ episode_id }} + 1 : {{ episode_id }} - 1;
        const lowestEpisodeId = {{ episodes }}.at(0);
        const highestEpisodeId = {{ episodes }}.at(-1);
        if(nextEpisodeId >= lowestEpisodeId && nextEpisodeId <= highestEpisodeId){
            window.location.href = `./episode_${nextEpisodeId}`;
        }
    }
}">
    <!-- Sidebar -->
    <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">
        <a href="https://github.com/huggingface/lerobot" target="_blank" class="hidden md:block">
            <img src="https://github.com/huggingface/lerobot/raw/main/media/lerobot-logo-thumbnail.png">
        </a>
        <a href="https://huggingface.co/datasets/{{ dataset_info.repo_id }}" target="_blank">
            <h1 class="mb-4 text-xl font-semibold">{{ dataset_info.repo_id }}</h1>
        </a>

        <ul>
            <li>
                Number of samples/frames: {{ dataset_info.num_samples }}
            </li>
            <li>
                Number of episodes: {{ dataset_info.num_episodes }}
            </li>
            <li>
                Frames per second: {{ dataset_info.fps }}
            </li>
        </ul>

        <p>Episodes:</p>
        <!-- episodes menu for medium & large screens -->
        <ul class="ml-2 hidden md:block">
            {% for episode in episodes %}
            <li class="font-mono text-sm mt-0.5">
                <a href="episode_{{ episode }}" class="underline {% if episode_id == episode %}font-bold -ml-1{% endif %}">
                    Episode {{ episode }}
                </a>
            </li>
            {% endfor %}
        </ul>

        <!-- episodes menu for small screens -->
        <div class="flex overflow-x-auto md:hidden">
            {% for episode in episodes %}
            <p class="font-mono text-sm mt-0.5 border-r last:border-r-0 px-2 {% if episode_id == episode %}font-bold{% endif %}">
                <a href="episode_{{ episode }}" class="">
                    {{ episode }}
                </a>
            </p>
            {% endfor %}
        </div>

    </div>

    <!-- Toggle sidebar button -->
    <button class="flex items-center opacity-50 hover:opacity-100 mx-1 hidden md:block"
        @click="() => ($refs.sidebar.classList.toggle('hidden'))" title="Toggle sidebar">
        <div class="bg-slate-500 w-2 h-10 rounded-full"></div>
    </button>

    <!-- Content -->
    <div class="max-h-screen flex flex-col gap-4 overflow-y-auto md:flex-1">
        <h1 class="text-xl font-bold mt-4 font-mono">
            Episode {{ episode_id }}
        </h1>

        <!-- Error message -->
        <div class="font-medium text-orange-700 hidden" :class="{ 'hidden': !videoCodecError }">
            <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>
            <ul class="list-decimal list-inside">
                <li>If iPhone: <span class="italic">It is supported with A17 chip or higher.</span></li>
                <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>
                <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>
            </ul>
        </div>

        <!-- Videos -->
        <div  class="max-w-32 relative text-sm mb-4 select-none"
            @click.outside="isVideosDropdownOpen = false">
            <div
                @click="isVideosDropdownOpen = !isVideosDropdownOpen"
                class="p-2 border border-slate-500 rounded flex justify-between items-center cursor-pointer"
            >
            <span class="truncate">filter videos</span>
            <div class="transition-transform" :class="{ 'rotate-180': isVideosDropdownOpen }">🔽</div>
            </div>

            <div x-show="isVideosDropdownOpen"
                class="absolute mt-1 border border-slate-500 rounded shadow-lg z-10">
            <div>
                <template x-for="option in videosKeys" :key="option">
                <div
                    @click="videosKeysSelected = videosKeysSelected.includes(option) ? videosKeysSelected.filter(v => v !== option) : [...videosKeysSelected, option]"
                    class="p-2 cursor-pointer bg-slate-900"
                    :class="{ 'bg-slate-700': videosKeysSelected.includes(option) }"
                    x-text="option"
                ></div>
                </template>
            </div>
            </div>
        </div>

        <div class="flex flex-wrap gap-x-2 gap-y-6">
            {% for video_info in videos_info %}
            <div x-show="!videoCodecError && videosKeysSelected.includes('{{ video_info.filename }}')" class="max-w-96 relative">
                <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>
                <video muted loop type="video/mp4" class="object-contain w-full h-full" @canplaythrough="videoCanPlay" @timeupdate="() => {
                    if (video.duration) {
                      const time = video.currentTime;
                      const pc = (100 / video.duration) * time;
                      $refs.slider.value = pc;
                      dygraphTime = time;
                      dygraphIndex = Math.floor(pc * dygraph.numRows() / 100);
                      dygraph.setSelection(dygraphIndex, undefined, true, true);

                      $refs.timer.textContent = formatTime(time) + ' / ' + formatTime(video.duration);

                      updateTimeQuery(time.toFixed(2));
                    }
                }" @ended="() => {
                    $refs.btnPlay.classList.remove('hidden');
                    $refs.btnPause.classList.add('hidden');
                }"
                    @loadedmetadata="() => ($refs.timer.textContent = formatTime(0) + ' / ' + formatTime(video.duration))">
                    <source src="{{ video_info.url }}">
                    Your browser does not support the video tag.
                </video>
            </div>
            {% endfor %}
        </div>

        <!-- Language instruction -->
        {% if videos_info[0].language_instruction %}
        <p class="font-medium mt-2">
            Language Instruction: <span class="italic">{{ videos_info[0].language_instruction }}</span>
        </p>
        {% endif %}

        <!-- Shortcuts info -->
        <div class="text-sm hidden md:block">
            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.
        </div>

        <!-- Controllers -->
        <div class="flex gap-1 text-3xl items-center">
            <button x-ref="btnPlay" class="-rotate-90" class="-rotate-90" title="Play. Toggle with Space" @click="() => {
                videos.forEach(video => video.play());
                $refs.btnPlay.classList.toggle('hidden');
                $refs.btnPause.classList.toggle('hidden');
            }">🔽</button>
            <button x-ref="btnPause" class="hidden" title="Pause. Toggle with Space" @click="() => {
                videos.forEach(video => video.pause());
                $refs.btnPlay.classList.toggle('hidden');
                $refs.btnPause.classList.toggle('hidden');
            }">⏸️</button>
            <button title="Jump backward 5 seconds"
                @click="() => (videos.forEach(video => (video.currentTime -= 5)))"></button>
            <button title="Jump forward 5 seconds"
                @click="() => (videos.forEach(video => (video.currentTime += 5)))"></button>
            <button title="Rewind from start"
                @click="() => (videos.forEach(video => (video.currentTime = 0.0)))">↩️</button>
            <input x-ref="slider" max="100" min="0" step="1" type="range" value="0" class="w-80 mx-2" @input="() => {
                const sliderValue = $refs.slider.value;
                videos.forEach(video => {
                    const time = (video.duration * sliderValue) / 100;
                    video.currentTime = time;
                });
            }" />
            <div x-ref="timer" class="font-mono text-sm border border-slate-500 rounded-lg px-1 py-0.5 shrink-0">0:00 /
                0:00
            </div>
        </div>

        <!-- Graph -->
        <div class="flex gap-2 mb-4 flex-wrap">
            <div>
                <div id="graph" @mouseleave="() => {
                    dygraph.setSelection(dygraphIndex, undefined, true, true);
                    dygraphTime = video.currentTime;
                }">
                </div>
                <p x-ref="graphTimer" class="font-mono ml-14 mt-4"
                    x-init="$watch('dygraphTime', value => ($refs.graphTimer.innerText = `Time: ${dygraphTime.toFixed(2)}s`))">
                    Time: 0.00s
                </p>
            </div>

            <table class="text-sm border-collapse border border-slate-700" x-show="currentFrameData">
                <thead>
                    <tr>
                        <th></th>
                        <template x-for="(_, colIndex) in Array.from({length: columns.length}, (_, index) => index)">
                            <th class="border border-slate-700">
                                <div class="flex gap-x-2 justify-between px-2">
                                    <input type="checkbox" :checked="isColumnChecked(colIndex)"
                                        @change="toggleColumn(colIndex)">
                                    <p x-text="`${columns[colIndex].key}`"></p>
                                </div>
                            </th>
                        </template>
                    </tr>
                </thead>
                <tbody>
                    <template x-for="(row, rowIndex) in rows">
                        <tr class="odd:bg-gray-800 even:bg-gray-900">
                            <td class="border border-slate-700">
                                <div class="flex gap-x-2 max-w-64 font-semibold px-1 break-all">
                                    <input type="checkbox" :checked="isRowChecked(rowIndex)"
                                        @change="toggleRow(rowIndex)">
                                    <p x-text="`${rowLabels[rowIndex]}`"></p>
                                </div>
                            </td>
                            <template x-for="(cell, colIndex) in row">
                                <td x-show="cell" class="border border-slate-700">
                                    <div class="flex gap-x-2 w-24 justify-between px-2" :class="{ 'hidden': cell.isNull }">
                                        <input type="checkbox" x-model="cell.checked" @change="updateTableValues()">
                                        <span x-text="`${!cell.isNull ? cell.value.toFixed(2) : null}`"
                                            :style="`color: ${cell.color}`"></span>
                                    </div>
                                </td>
                            </template>
                        </tr>
                    </template>
                </tbody>
            </table>

            <div id="labels" class="hidden">
            </div>
        </div>
    </div>

    <script>
        const parentOrigin = "https://huggingface.co";
        const searchParams = new URLSearchParams();
        searchParams.set("dataset", "{{ dataset_info.repo_id }}");
        searchParams.set("episode", "{{ episode_id }}");
		window.parent.postMessage({ queryString: searchParams.toString() }, parentOrigin);
    </script>

    <script>
        function createAlpineData() {
            return {
                // state
                dygraph: null,
                currentFrameData: null,
                checked: [],
                dygraphTime: 0.0,
                dygraphIndex: 0,
                videos: null,
                video: null,
                colors: null,
                nVideos: {{ videos_info | length }},
                nVideoReadyToPlay: 0,
                videoCodecError: false,
                isVideosDropdownOpen: false,
                videosKeys: {{ videos_info | map(attribute='filename') | list | tojson }},
                videosKeysSelected: [],
                columns: {{ columns | tojson }},
                rowLabels: {{ columns | tojson }}.reduce((colA, colB) => colA.value.length > colB.value.length ? colA : colB).value,

                // alpine initialization
                init() {
                    // check if videos can play
                    const dummyVideo = document.createElement('video');
                    const canPlayVideos = dummyVideo.canPlayType('video/mp4; codecs="av01.0.05M.08"'); // codec source: https://huggingface.co/blog/video-encoding#results
                    if(!canPlayVideos){
                        this.videoCodecError = true;
                    }
                    this.videosKeysSelected = this.videosKeys.map(opt => opt)

                    // process CSV data
                    const csvDataStr = {{ episode_data_csv_str|tojson|safe }};
                    // Create a Blob with the CSV data
                    const blob = new Blob([csvDataStr], { type: 'text/csv;charset=utf-8;' });
                    // Create a URL for the Blob
                    const csvUrl = URL.createObjectURL(blob);

                    // process CSV data
                    this.videos = document.querySelectorAll('video');
                    this.video = this.videos[0];
                    this.dygraph = new Dygraph(document.getElementById("graph"), csvUrl, {
                        pixelsPerPoint: 0.01,
                        legend: 'always',
                        labelsDiv: document.getElementById('labels'),
                        labelsKMB: true,
                        strokeWidth: 1.5,
                        pointClickCallback: (event, point) => {
                            this.dygraphTime = point.xval;
                            this.updateTableValues(this.dygraphTime);
                        },
                        highlightCallback: (event, x, points, row, seriesName) => {
                            this.dygraphTime = x;
                            this.updateTableValues(this.dygraphTime);
                        },
                        drawCallback: (dygraph, is_initial) => {
                            if (is_initial) {
                                // dygraph initialization
                                this.dygraph.setSelection(this.dygraphIndex, undefined, true, true);
                                this.colors = this.dygraph.getColors();
                                this.checked = Array(this.colors.length).fill(true);

                                const colors = [];
                                let lightness = 30; // const LIGHTNESS = [30, 65, 85]; // state_lightness, action_lightness, pred_action_lightness
                                for(const column of this.columns){
                                    const nValues = column.value.length;
                                    for (let hue = 0; hue < 360; hue += parseInt(360/nValues)) {
                                        const color = `hsl(${hue}, 100%, ${lightness}%)`;
                                        colors.push(color);
                                    }
                                    lightness += 35;
                                }

                                this.dygraph.updateOptions({ colors });
                                this.colors = colors;

                                this.updateTableValues();

                                let url = new URL(window.location.href);
                                let params = new URLSearchParams(url.search);
                                let time = params.get("t");
                                if(time){
                                    time = parseFloat(time);
                                    this.videos.forEach(video => (video.currentTime = time));
                                }
                            }
                        },
                    });
                },

                //#region Table Data

                // turn dygraph's 1D data (at a given time t) to 2D data that whose columns names are defined in this.columnNames.
                // 2d data view is used to create html table element.
                get rows() {
                    if (!this.currentFrameData) {
                        return [];
                    }
                    const rows = [];
                    const nRows = Math.max(...this.columns.map(column => column.value.length));
                    let rowIndex = 0;
                    while(rowIndex < nRows){
                        const row = [];
                        // 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
                        const nullCell = { isNull: true };
                        // row consists of [state value, action value]
                        let idx = rowIndex;
                        for(const column of this.columns){
                            const nColumn = column.value.length;
                            row.push(rowIndex < nColumn ? this.currentFrameData[idx] : nullCell);
                            idx += nColumn; // because this.currentFrameData = [state0, state1, ..., stateN, action0, action1, ..., actionN]
                        }
                        rowIndex += 1;
                        rows.push(row);
                    }
                    return rows;
                },
                isRowChecked(rowIndex) {
                    return this.rows[rowIndex].every(cell => cell && (cell.isNull || cell.checked));
                },
                isColumnChecked(colIndex) {
                    return this.rows.every(row => row[colIndex] && (row[colIndex].isNull || row[colIndex].checked));
                },
                toggleRow(rowIndex) {
                    const newState = !this.isRowChecked(rowIndex);
                    this.rows[rowIndex].forEach(cell => {
                        if (cell && !cell.isNull) cell.checked = newState;
                    });
                    this.updateTableValues();
                },
                toggleColumn(colIndex) {
                    const newState = !this.isColumnChecked(colIndex);
                    this.rows.forEach(row => {
                        if (row[colIndex] && !row[colIndex].isNull) row[colIndex].checked = newState;
                    });
                    this.updateTableValues();
                },

                // given time t, update the values in the html table with "data[t]"
                updateTableValues(time) {
                    if (!this.colors) {
                        return;
                    }
                    let pc = (100 / this.video.duration) * (time === undefined ? this.video.currentTime : time);
                    if (isNaN(pc)) pc = 0;
                    const index = Math.floor(pc * this.dygraph.numRows() / 100);
                    // slice(1) to remove the timestamp point that we do not need
                    const labels = this.dygraph.getLabels().slice(1);
                    const values = this.dygraph.rawData_[index].slice(1);
                    const checkedNew = this.currentFrameData ? this.currentFrameData.map(cell => cell.checked) : Array(
                        this.colors.length).fill(true);
                    this.currentFrameData = labels.map((label, idx) => ({
                        label,
                        value: values[idx],
                        color: this.colors[idx],
                        checked: checkedNew[idx],
                    }));
                    const shouldUpdateVisibility = !this.checked.every((value, index) => value === checkedNew[index]);
                    if (shouldUpdateVisibility) {
                        this.checked = checkedNew;
                        this.dygraph.setVisibility(this.checked);
                    }
                },

                //#endregion

                updateTimeQuery(time) {
                    let url = new URL(window.location.href);
                    let params = new URLSearchParams(url.search);
                    params.set("t", time);
                    url.search = params.toString();
                    window.history.replaceState({}, '', url.toString());
                },

                formatTime(time) {
                    var hours = Math.floor(time / 3600);
                    var minutes = Math.floor((time % 3600) / 60);
                    var seconds = Math.floor(time % 60);
                    return (hours > 0 ? hours + ':' : '') + (minutes < 10 ? '0' + minutes : minutes) + ':' + (seconds <
                        10 ?
                        '0' + seconds : seconds);
                },

                videoCanPlay() {
                    this.nVideoReadyToPlay += 1;
                    if(this.nVideoReadyToPlay == this.nVideos) {
                        // start autoplay all videos in sync
                        this.$refs.btnPlay.click();
                    }
                }
            };
        }
    </script>
</body>

</html>