|
|
|
<!DOCTYPE html> |
|
<html lang="en"> |
|
|
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
|
<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> |
|
|
|
|
|
|
|
|
|
<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}`; |
|
} |
|
} |
|
}"> |
|
|
|
<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> |
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
{% 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 %} |
|
|
|
|
|
<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> |
|
|
|
|
|
<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> |
|
|
|
|
|
<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 { |
|
|
|
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, |
|
|
|
|
|
init() { |
|
|
|
const dummyVideo = document.createElement('video'); |
|
const canPlayVideos = dummyVideo.canPlayType('video/mp4; codecs="av01.0.05M.08"'); |
|
if(!canPlayVideos){ |
|
this.videoCodecError = true; |
|
} |
|
this.videosKeysSelected = this.videosKeys.map(opt => opt) |
|
|
|
|
|
const csvDataStr = {{ episode_data_csv_str|tojson|safe }}; |
|
|
|
const blob = new Blob([csvDataStr], { type: 'text/csv;charset=utf-8;' }); |
|
|
|
const csvUrl = URL.createObjectURL(blob); |
|
|
|
|
|
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) { |
|
|
|
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; |
|
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)); |
|
} |
|
} |
|
}, |
|
}); |
|
}, |
|
|
|
|
|
|
|
|
|
|
|
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 = []; |
|
|
|
const nullCell = { isNull: true }; |
|
|
|
let idx = rowIndex; |
|
for(const column of this.columns){ |
|
const nColumn = column.value.length; |
|
row.push(rowIndex < nColumn ? this.currentFrameData[idx] : nullCell); |
|
idx += nColumn; |
|
} |
|
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(); |
|
}, |
|
|
|
|
|
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); |
|
|
|
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); |
|
} |
|
}, |
|
|
|
|
|
|
|
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) { |
|
|
|
this.$refs.btnPlay.click(); |
|
} |
|
} |
|
}; |
|
} |
|
</script> |
|
</body> |
|
|
|
</html> |