Spaces:
Sleeping
Sleeping
Add stats, 3d viewer, begin/end image visualization
Browse files- package-lock.json +0 -0
- package.json +5 -1
- src/app/[org]/[dataset]/[episode]/actions.ts +30 -0
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +215 -74
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +298 -15
- src/components/overview-panel.tsx +181 -0
- src/components/side-nav.tsx +20 -36
- src/components/stats-panel.tsx +335 -0
- src/components/urdf-viewer.tsx +366 -0
- src/lib/so101-robot.ts +154 -0
package-lock.json
ADDED
|
The diff for this file is too large to render.
See raw diff
|
|
|
package.json
CHANGED
|
@@ -10,12 +10,16 @@
|
|
| 10 |
"format": "prettier --write ."
|
| 11 |
},
|
| 12 |
"dependencies": {
|
|
|
|
|
|
|
|
|
|
| 13 |
"hyparquet": "^1.12.1",
|
| 14 |
"next": "15.3.6",
|
| 15 |
"react": "^19.0.0",
|
| 16 |
"react-dom": "^19.0.0",
|
| 17 |
"react-icons": "^5.5.0",
|
| 18 |
-
"recharts": "^2.15.3"
|
|
|
|
| 19 |
},
|
| 20 |
"devDependencies": {
|
| 21 |
"@eslint/eslintrc": "^3",
|
|
|
|
| 10 |
"format": "prettier --write ."
|
| 11 |
},
|
| 12 |
"dependencies": {
|
| 13 |
+
"@react-three/drei": "^10.7.7",
|
| 14 |
+
"@react-three/fiber": "^9.5.0",
|
| 15 |
+
"@types/three": "^0.182.0",
|
| 16 |
"hyparquet": "^1.12.1",
|
| 17 |
"next": "15.3.6",
|
| 18 |
"react": "^19.0.0",
|
| 19 |
"react-dom": "^19.0.0",
|
| 20 |
"react-icons": "^5.5.0",
|
| 21 |
+
"recharts": "^2.15.3",
|
| 22 |
+
"three": "^0.182.0"
|
| 23 |
},
|
| 24 |
"devDependencies": {
|
| 25 |
"@eslint/eslintrc": "^3",
|
src/app/[org]/[dataset]/[episode]/actions.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use server";
|
| 2 |
+
|
| 3 |
+
import { getDatasetVersionAndInfo } from "@/utils/versionUtils";
|
| 4 |
+
import type { DatasetMetadata } from "@/utils/parquetUtils";
|
| 5 |
+
import {
|
| 6 |
+
loadAllEpisodeLengthsV3,
|
| 7 |
+
loadAllEpisodeFrameInfo,
|
| 8 |
+
type EpisodeLengthStats,
|
| 9 |
+
type EpisodeFramesData,
|
| 10 |
+
} from "./fetch-data";
|
| 11 |
+
|
| 12 |
+
export async function fetchEpisodeLengthStats(
|
| 13 |
+
org: string,
|
| 14 |
+
dataset: string,
|
| 15 |
+
): Promise<EpisodeLengthStats | null> {
|
| 16 |
+
const repoId = `${org}/${dataset}`;
|
| 17 |
+
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 18 |
+
if (version !== "v3.0") return null;
|
| 19 |
+
return loadAllEpisodeLengthsV3(repoId, version, info.fps);
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
export async function fetchEpisodeFrames(
|
| 23 |
+
org: string,
|
| 24 |
+
dataset: string,
|
| 25 |
+
): Promise<EpisodeFramesData> {
|
| 26 |
+
const repoId = `${org}/${dataset}`;
|
| 27 |
+
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 28 |
+
return loadAllEpisodeFrameInfo(repoId, version, info as unknown as DatasetMetadata);
|
| 29 |
+
}
|
| 30 |
+
|
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import { useState, useEffect, useRef } from "react";
|
| 4 |
import { useRouter, useSearchParams } from "next/navigation";
|
| 5 |
import { postParentMessageWithParams } from "@/utils/postParentMessage";
|
| 6 |
import { SimpleVideosPlayer } from "@/components/simple-videos-player";
|
|
@@ -8,8 +8,23 @@ import DataRecharts from "@/components/data-recharts";
|
|
| 8 |
import PlaybackBar from "@/components/playback-bar";
|
| 9 |
import { TimeProvider, useTime } from "@/context/time-context";
|
| 10 |
import Sidebar from "@/components/side-nav";
|
|
|
|
|
|
|
| 11 |
import Loading from "@/components/loading-component";
|
| 12 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
export default function EpisodeViewer({
|
| 15 |
data,
|
|
@@ -63,7 +78,47 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 63 |
const router = useRouter();
|
| 64 |
const searchParams = useSearchParams();
|
| 65 |
|
| 66 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
// Use context for time sync
|
| 68 |
const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
|
| 69 |
|
|
@@ -191,85 +246,171 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 191 |
};
|
| 192 |
|
| 193 |
return (
|
| 194 |
-
<div className="flex h-screen max-h-screen bg-slate-950 text-gray-200">
|
| 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 |
-
<div>
|
| 226 |
-
<a
|
| 227 |
-
href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
|
| 228 |
-
target="_blank"
|
| 229 |
-
>
|
| 230 |
-
<p className="text-lg font-semibold">{datasetInfo.repoId}</p>
|
| 231 |
-
</a>
|
| 232 |
-
|
| 233 |
-
<p className="font-mono text-lg font-semibold">
|
| 234 |
-
episode {episodeId}
|
| 235 |
-
</p>
|
| 236 |
-
</div>
|
| 237 |
-
</div>
|
| 238 |
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
/>
|
| 245 |
)}
|
| 246 |
|
| 247 |
-
{/*
|
| 248 |
-
|
| 249 |
-
|
| 250 |
-
|
| 251 |
-
|
| 252 |
-
</p>
|
| 253 |
-
<div className="mt-2 text-slate-300">
|
| 254 |
-
{task.split('\n').map((instruction: string, index: number) => (
|
| 255 |
-
<p key={index} className="mb-1">
|
| 256 |
-
{instruction}
|
| 257 |
-
</p>
|
| 258 |
-
))}
|
| 259 |
-
</div>
|
| 260 |
-
</div>
|
| 261 |
-
)}
|
| 262 |
|
| 263 |
-
|
| 264 |
-
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
|
| 268 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 271 |
|
| 272 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 273 |
</div>
|
| 274 |
</div>
|
| 275 |
);
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState, useEffect, useRef, lazy, Suspense } from "react";
|
| 4 |
import { useRouter, useSearchParams } from "next/navigation";
|
| 5 |
import { postParentMessageWithParams } from "@/utils/postParentMessage";
|
| 6 |
import { SimpleVideosPlayer } from "@/components/simple-videos-player";
|
|
|
|
| 8 |
import PlaybackBar from "@/components/playback-bar";
|
| 9 |
import { TimeProvider, useTime } from "@/context/time-context";
|
| 10 |
import Sidebar from "@/components/side-nav";
|
| 11 |
+
import StatsPanel from "@/components/stats-panel";
|
| 12 |
+
import OverviewPanel from "@/components/overview-panel";
|
| 13 |
import Loading from "@/components/loading-component";
|
| 14 |
+
import { isSO101Robot } from "@/lib/so101-robot";
|
| 15 |
+
import {
|
| 16 |
+
getAdjacentEpisodesVideoInfo,
|
| 17 |
+
computeColumnMinMax,
|
| 18 |
+
type EpisodeData,
|
| 19 |
+
type ColumnMinMax,
|
| 20 |
+
type EpisodeLengthStats,
|
| 21 |
+
type EpisodeFramesData,
|
| 22 |
+
} from "./fetch-data";
|
| 23 |
+
import { fetchEpisodeLengthStats, fetchEpisodeFrames } from "./actions";
|
| 24 |
+
|
| 25 |
+
const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
|
| 26 |
+
|
| 27 |
+
type ActiveTab = "episodes" | "statistics" | "frames" | "urdf";
|
| 28 |
|
| 29 |
export default function EpisodeViewer({
|
| 30 |
data,
|
|
|
|
| 78 |
const router = useRouter();
|
| 79 |
const searchParams = useSearchParams();
|
| 80 |
|
| 81 |
+
// Tab state & lazy stats
|
| 82 |
+
const [activeTab, setActiveTab] = useState<ActiveTab>("episodes");
|
| 83 |
+
const [columnMinMax, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
|
| 84 |
+
const [episodeLengthStats, setEpisodeLengthStats] = useState<EpisodeLengthStats | null>(null);
|
| 85 |
+
const [statsLoading, setStatsLoading] = useState(false);
|
| 86 |
+
const statsLoadedRef = useRef(false);
|
| 87 |
+
const [episodeFramesData, setEpisodeFramesData] = useState<EpisodeFramesData | null>(null);
|
| 88 |
+
const [framesLoading, setFramesLoading] = useState(false);
|
| 89 |
+
const framesLoadedRef = useRef(false);
|
| 90 |
+
|
| 91 |
+
const loadStats = () => {
|
| 92 |
+
if (statsLoadedRef.current) return;
|
| 93 |
+
statsLoadedRef.current = true;
|
| 94 |
+
setStatsLoading(true);
|
| 95 |
+
setColumnMinMax(computeColumnMinMax(data.chartDataGroups));
|
| 96 |
+
if (org && dataset) {
|
| 97 |
+
fetchEpisodeLengthStats(org, dataset)
|
| 98 |
+
.then((result) => setEpisodeLengthStats(result))
|
| 99 |
+
.catch(() => {})
|
| 100 |
+
.finally(() => setStatsLoading(false));
|
| 101 |
+
} else {
|
| 102 |
+
setStatsLoading(false);
|
| 103 |
+
}
|
| 104 |
+
};
|
| 105 |
+
|
| 106 |
+
const loadFrames = () => {
|
| 107 |
+
if (framesLoadedRef.current || !org || !dataset) return;
|
| 108 |
+
framesLoadedRef.current = true;
|
| 109 |
+
setFramesLoading(true);
|
| 110 |
+
fetchEpisodeFrames(org, dataset)
|
| 111 |
+
.then(setEpisodeFramesData)
|
| 112 |
+
.catch(() => setEpisodeFramesData({ cameras: [], framesByCamera: {} }))
|
| 113 |
+
.finally(() => setFramesLoading(false));
|
| 114 |
+
};
|
| 115 |
+
|
| 116 |
+
const handleTabChange = (tab: ActiveTab) => {
|
| 117 |
+
setActiveTab(tab);
|
| 118 |
+
if (tab === "statistics") loadStats();
|
| 119 |
+
if (tab === "frames") loadFrames();
|
| 120 |
+
};
|
| 121 |
+
|
| 122 |
// Use context for time sync
|
| 123 |
const { currentTime, setCurrentTime, setIsPlaying, isPlaying } = useTime();
|
| 124 |
|
|
|
|
| 246 |
};
|
| 247 |
|
| 248 |
return (
|
| 249 |
+
<div className="flex flex-col h-screen max-h-screen bg-slate-950 text-gray-200">
|
| 250 |
+
{/* Top tab bar */}
|
| 251 |
+
<div className="flex items-center border-b border-slate-700 bg-slate-900 shrink-0">
|
| 252 |
+
<button
|
| 253 |
+
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 254 |
+
activeTab === "episodes"
|
| 255 |
+
? "text-orange-400"
|
| 256 |
+
: "text-slate-400 hover:text-slate-200"
|
| 257 |
+
}`}
|
| 258 |
+
onClick={() => handleTabChange("episodes")}
|
| 259 |
+
>
|
| 260 |
+
Episodes
|
| 261 |
+
{activeTab === "episodes" && (
|
| 262 |
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 263 |
+
)}
|
| 264 |
+
</button>
|
| 265 |
+
<button
|
| 266 |
+
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 267 |
+
activeTab === "statistics"
|
| 268 |
+
? "text-orange-400"
|
| 269 |
+
: "text-slate-400 hover:text-slate-200"
|
| 270 |
+
}`}
|
| 271 |
+
onClick={() => handleTabChange("statistics")}
|
| 272 |
+
>
|
| 273 |
+
Statistics
|
| 274 |
+
{activeTab === "statistics" && (
|
| 275 |
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 276 |
+
)}
|
| 277 |
+
</button>
|
| 278 |
+
<button
|
| 279 |
+
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 280 |
+
activeTab === "frames"
|
| 281 |
+
? "text-orange-400"
|
| 282 |
+
: "text-slate-400 hover:text-slate-200"
|
| 283 |
+
}`}
|
| 284 |
+
onClick={() => handleTabChange("frames")}
|
| 285 |
+
>
|
| 286 |
+
Frames
|
| 287 |
+
{activeTab === "frames" && (
|
| 288 |
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 289 |
+
)}
|
| 290 |
+
</button>
|
| 291 |
+
{isSO101Robot(datasetInfo.robot_type) && (
|
| 292 |
+
<button
|
| 293 |
+
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 294 |
+
activeTab === "urdf"
|
| 295 |
+
? "text-orange-400"
|
| 296 |
+
: "text-slate-400 hover:text-slate-200"
|
| 297 |
+
}`}
|
| 298 |
+
onClick={() => handleTabChange("urdf")}
|
| 299 |
>
|
| 300 |
+
3D Replay
|
| 301 |
+
{activeTab === "urdf" && (
|
| 302 |
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 303 |
+
)}
|
| 304 |
+
</button>
|
| 305 |
+
)}
|
| 306 |
+
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 307 |
|
| 308 |
+
{/* Body: sidebar + content */}
|
| 309 |
+
<div className="flex flex-1 min-h-0">
|
| 310 |
+
{/* Sidebar — only on Episodes tab */}
|
| 311 |
+
{activeTab === "episodes" && (
|
| 312 |
+
<Sidebar
|
| 313 |
+
datasetInfo={datasetInfo}
|
| 314 |
+
paginatedEpisodes={paginatedEpisodes}
|
| 315 |
+
episodeId={episodeId}
|
| 316 |
+
totalPages={totalPages}
|
| 317 |
+
currentPage={currentPage}
|
| 318 |
+
prevPage={prevPage}
|
| 319 |
+
nextPage={nextPage}
|
| 320 |
/>
|
| 321 |
)}
|
| 322 |
|
| 323 |
+
{/* Main content */}
|
| 324 |
+
<div
|
| 325 |
+
className={`flex flex-col gap-4 p-4 flex-1 relative ${isLoading ? "overflow-hidden" : "overflow-y-auto"}`}
|
| 326 |
+
>
|
| 327 |
+
{isLoading && <Loading />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
|
| 329 |
+
{activeTab === "episodes" && (
|
| 330 |
+
<>
|
| 331 |
+
<div className="flex items-center justify-start my-4">
|
| 332 |
+
<a
|
| 333 |
+
href="https://github.com/huggingface/lerobot"
|
| 334 |
+
target="_blank"
|
| 335 |
+
className="block"
|
| 336 |
+
>
|
| 337 |
+
<img
|
| 338 |
+
src="https://github.com/huggingface/lerobot/raw/main/media/readme/lerobot-logo-thumbnail.png"
|
| 339 |
+
alt="LeRobot Logo"
|
| 340 |
+
className="w-32"
|
| 341 |
+
/>
|
| 342 |
+
</a>
|
| 343 |
|
| 344 |
+
<div>
|
| 345 |
+
<a
|
| 346 |
+
href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
|
| 347 |
+
target="_blank"
|
| 348 |
+
>
|
| 349 |
+
<p className="text-lg font-semibold">{datasetInfo.repoId}</p>
|
| 350 |
+
</a>
|
| 351 |
+
|
| 352 |
+
<p className="font-mono text-lg font-semibold">
|
| 353 |
+
episode {episodeId}
|
| 354 |
+
</p>
|
| 355 |
+
</div>
|
| 356 |
+
</div>
|
| 357 |
+
|
| 358 |
+
{/* Videos */}
|
| 359 |
+
{videosInfo.length > 0 && (
|
| 360 |
+
<SimpleVideosPlayer
|
| 361 |
+
videosInfo={videosInfo}
|
| 362 |
+
onVideosReady={() => setVideosReady(true)}
|
| 363 |
+
/>
|
| 364 |
+
)}
|
| 365 |
+
|
| 366 |
+
{/* Language Instruction */}
|
| 367 |
+
{task && (
|
| 368 |
+
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
|
| 369 |
+
<p className="text-slate-300">
|
| 370 |
+
<span className="font-semibold text-slate-100">Language Instruction:</span>
|
| 371 |
+
</p>
|
| 372 |
+
<div className="mt-2 text-slate-300">
|
| 373 |
+
{task.split('\n').map((instruction: string, index: number) => (
|
| 374 |
+
<p key={index} className="mb-1">
|
| 375 |
+
{instruction}
|
| 376 |
+
</p>
|
| 377 |
+
))}
|
| 378 |
+
</div>
|
| 379 |
+
</div>
|
| 380 |
+
)}
|
| 381 |
|
| 382 |
+
{/* Graph */}
|
| 383 |
+
<div className="mb-4">
|
| 384 |
+
<DataRecharts
|
| 385 |
+
data={chartDataGroups}
|
| 386 |
+
onChartsReady={() => setChartsReady(true)}
|
| 387 |
+
/>
|
| 388 |
+
</div>
|
| 389 |
+
|
| 390 |
+
<PlaybackBar />
|
| 391 |
+
</>
|
| 392 |
+
)}
|
| 393 |
+
|
| 394 |
+
{activeTab === "statistics" && (
|
| 395 |
+
<StatsPanel
|
| 396 |
+
datasetInfo={datasetInfo}
|
| 397 |
+
episodeId={episodeId}
|
| 398 |
+
columnMinMax={columnMinMax}
|
| 399 |
+
episodeLengthStats={episodeLengthStats}
|
| 400 |
+
loading={statsLoading}
|
| 401 |
+
/>
|
| 402 |
+
)}
|
| 403 |
+
|
| 404 |
+
{activeTab === "frames" && (
|
| 405 |
+
<OverviewPanel data={episodeFramesData} loading={framesLoading} />
|
| 406 |
+
)}
|
| 407 |
+
|
| 408 |
+
{activeTab === "urdf" && (
|
| 409 |
+
<Suspense fallback={<Loading />}>
|
| 410 |
+
<URDFViewer data={data} />
|
| 411 |
+
</Suspense>
|
| 412 |
+
)}
|
| 413 |
+
</div>
|
| 414 |
</div>
|
| 415 |
</div>
|
| 416 |
);
|
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -18,20 +18,62 @@ export type VideoInfo = {
|
|
| 18 |
segmentDuration?: number;
|
| 19 |
};
|
| 20 |
|
|
|
|
|
|
|
| 21 |
export type DatasetDisplayInfo = {
|
| 22 |
repoId: string;
|
| 23 |
total_frames: number;
|
| 24 |
total_episodes: number;
|
| 25 |
fps: number;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
};
|
| 27 |
|
| 28 |
export type ChartRow = Record<string, number | Record<string, number>>;
|
| 29 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
export type EpisodeData = {
|
| 31 |
datasetInfo: DatasetDisplayInfo;
|
| 32 |
episodeId: number;
|
| 33 |
videosInfo: VideoInfo[];
|
| 34 |
chartDataGroups: ChartRow[][];
|
|
|
|
| 35 |
episodes: number[];
|
| 36 |
ignoredColumns: string[];
|
| 37 |
duration: number;
|
|
@@ -107,6 +149,21 @@ export async function getEpisodeData(
|
|
| 107 |
? await getEpisodeDataV3(repoId, version, info, episodeId)
|
| 108 |
: await getEpisodeDataV2(repoId, version, info, episodeId);
|
| 109 |
console.timeEnd(`[perf] getEpisodeData (${version})`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
return result;
|
| 111 |
} catch (err) {
|
| 112 |
console.error("Error loading episode data:", err);
|
|
@@ -181,12 +238,16 @@ async function getEpisodeDataV2(
|
|
| 181 |
): Promise<EpisodeData> {
|
| 182 |
const episode_chunk = Math.floor(0 / 1000);
|
| 183 |
|
| 184 |
-
|
| 185 |
-
const datasetInfo = {
|
| 186 |
repoId,
|
| 187 |
total_frames: info.total_frames,
|
| 188 |
total_episodes: info.total_episodes,
|
| 189 |
fps: info.fps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 190 |
};
|
| 191 |
|
| 192 |
// Generate list of episodes
|
|
@@ -440,6 +501,7 @@ async function getEpisodeDataV2(
|
|
| 440 |
episodeId,
|
| 441 |
videosInfo,
|
| 442 |
chartDataGroups,
|
|
|
|
| 443 |
episodes,
|
| 444 |
ignoredColumns,
|
| 445 |
duration,
|
|
@@ -454,15 +516,18 @@ async function getEpisodeDataV3(
|
|
| 454 |
info: DatasetMetadata,
|
| 455 |
episodeId: number,
|
| 456 |
): Promise<EpisodeData> {
|
| 457 |
-
|
| 458 |
-
const datasetInfo = {
|
| 459 |
repoId,
|
| 460 |
total_frames: info.total_frames,
|
| 461 |
total_episodes: info.total_episodes,
|
| 462 |
fps: info.fps,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
};
|
| 464 |
|
| 465 |
-
// Generate episodes list based on total_episodes from dataset info
|
| 466 |
const episodes = Array.from({ length: info.total_episodes }, (_, i) => i);
|
| 467 |
|
| 468 |
// Load episode metadata to get timestamps for episode 0
|
|
@@ -472,17 +537,17 @@ async function getEpisodeDataV3(
|
|
| 472 |
const videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
|
| 473 |
|
| 474 |
// Load episode data for charts
|
| 475 |
-
const { chartDataGroups, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
|
| 476 |
|
| 477 |
-
// Calculate duration from episode length and FPS if available
|
| 478 |
const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
|
| 479 |
(episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
|
| 480 |
-
|
| 481 |
return {
|
| 482 |
datasetInfo,
|
| 483 |
episodeId,
|
| 484 |
videosInfo,
|
| 485 |
chartDataGroups,
|
|
|
|
| 486 |
episodes,
|
| 487 |
ignoredColumns,
|
| 488 |
duration,
|
|
@@ -496,7 +561,7 @@ async function loadEpisodeDataV3(
|
|
| 496 |
version: string,
|
| 497 |
info: DatasetMetadata,
|
| 498 |
episodeMetadata: EpisodeMetadataV3,
|
| 499 |
-
): Promise<{ chartDataGroups: ChartRow[][]; ignoredColumns: string[]; task?: string }> {
|
| 500 |
// Build data file path using chunk and file indices
|
| 501 |
const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
|
| 502 |
const dataFileIndex = episodeMetadata.data_file_index || 0;
|
|
@@ -526,11 +591,11 @@ async function loadEpisodeDataV3(
|
|
| 526 |
const episodeData = fullData.slice(localFromIndex, localToIndex);
|
| 527 |
|
| 528 |
if (episodeData.length === 0) {
|
| 529 |
-
return { chartDataGroups: [], ignoredColumns: [], task: undefined };
|
| 530 |
}
|
| 531 |
|
| 532 |
// Convert to the same format as v2.x for compatibility with existing chart code
|
| 533 |
-
const { chartDataGroups, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
|
| 534 |
|
| 535 |
// First check for language_instruction fields in the data (preferred)
|
| 536 |
let task: string | undefined;
|
|
@@ -586,9 +651,9 @@ async function loadEpisodeDataV3(
|
|
| 586 |
}
|
| 587 |
}
|
| 588 |
|
| 589 |
-
return { chartDataGroups, ignoredColumns, task };
|
| 590 |
} catch {
|
| 591 |
-
return { chartDataGroups: [], ignoredColumns: [], task: undefined };
|
| 592 |
}
|
| 593 |
}
|
| 594 |
|
|
@@ -597,7 +662,7 @@ function processEpisodeDataForCharts(
|
|
| 597 |
episodeData: Record<string, unknown>[],
|
| 598 |
info: DatasetMetadata,
|
| 599 |
episodeMetadata?: EpisodeMetadataV3,
|
| 600 |
-
): { chartDataGroups: ChartRow[][]; ignoredColumns: string[] } {
|
| 601 |
|
| 602 |
// Get numeric column features
|
| 603 |
const columnNames = Object.entries(info.features)
|
|
@@ -854,7 +919,7 @@ function processEpisodeDataForCharts(
|
|
| 854 |
chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
|
| 855 |
);
|
| 856 |
|
| 857 |
-
return { chartDataGroups, ignoredColumns };
|
| 858 |
}
|
| 859 |
|
| 860 |
|
|
@@ -1054,6 +1119,224 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
|
|
| 1054 |
|
| 1055 |
|
| 1056 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1057 |
// Safe wrapper for UI error display
|
| 1058 |
export async function getEpisodeDataSafe(
|
| 1059 |
org: string,
|
|
|
|
| 18 |
segmentDuration?: number;
|
| 19 |
};
|
| 20 |
|
| 21 |
+
export type CameraInfo = { name: string; width: number; height: number };
|
| 22 |
+
|
| 23 |
export type DatasetDisplayInfo = {
|
| 24 |
repoId: string;
|
| 25 |
total_frames: number;
|
| 26 |
total_episodes: number;
|
| 27 |
fps: number;
|
| 28 |
+
robot_type: string | null;
|
| 29 |
+
codebase_version: string;
|
| 30 |
+
total_tasks: number;
|
| 31 |
+
dataset_size_mb: number;
|
| 32 |
+
cameras: CameraInfo[];
|
| 33 |
};
|
| 34 |
|
| 35 |
export type ChartRow = Record<string, number | Record<string, number>>;
|
| 36 |
|
| 37 |
+
export type ColumnMinMax = {
|
| 38 |
+
column: string;
|
| 39 |
+
min: number;
|
| 40 |
+
max: number;
|
| 41 |
+
};
|
| 42 |
+
|
| 43 |
+
export type EpisodeLengthInfo = {
|
| 44 |
+
episodeIndex: number;
|
| 45 |
+
lengthSeconds: number;
|
| 46 |
+
frames: number;
|
| 47 |
+
};
|
| 48 |
+
|
| 49 |
+
export type EpisodeLengthStats = {
|
| 50 |
+
shortestEpisodes: EpisodeLengthInfo[];
|
| 51 |
+
longestEpisodes: EpisodeLengthInfo[];
|
| 52 |
+
allEpisodeLengths: EpisodeLengthInfo[];
|
| 53 |
+
meanEpisodeLength: number;
|
| 54 |
+
medianEpisodeLength: number;
|
| 55 |
+
stdEpisodeLength: number;
|
| 56 |
+
episodeLengthHistogram: { binLabel: string; count: number }[];
|
| 57 |
+
};
|
| 58 |
+
|
| 59 |
+
export type EpisodeFrameInfo = {
|
| 60 |
+
episodeIndex: number;
|
| 61 |
+
videoUrl: string;
|
| 62 |
+
firstFrameTime: number;
|
| 63 |
+
lastFrameTime: number | null; // null = seek to video.duration on client
|
| 64 |
+
};
|
| 65 |
+
|
| 66 |
+
export type EpisodeFramesData = {
|
| 67 |
+
cameras: string[];
|
| 68 |
+
framesByCamera: Record<string, EpisodeFrameInfo[]>;
|
| 69 |
+
};
|
| 70 |
+
|
| 71 |
export type EpisodeData = {
|
| 72 |
datasetInfo: DatasetDisplayInfo;
|
| 73 |
episodeId: number;
|
| 74 |
videosInfo: VideoInfo[];
|
| 75 |
chartDataGroups: ChartRow[][];
|
| 76 |
+
flatChartData: Record<string, number>[];
|
| 77 |
episodes: number[];
|
| 78 |
ignoredColumns: string[];
|
| 79 |
duration: number;
|
|
|
|
| 149 |
? await getEpisodeDataV3(repoId, version, info, episodeId)
|
| 150 |
: await getEpisodeDataV2(repoId, version, info, episodeId);
|
| 151 |
console.timeEnd(`[perf] getEpisodeData (${version})`);
|
| 152 |
+
|
| 153 |
+
// Extract camera resolutions from features
|
| 154 |
+
const cameras: CameraInfo[] = Object.entries(rawInfo.features)
|
| 155 |
+
.filter(([, f]) => f.dtype === "video" && f.shape.length >= 2)
|
| 156 |
+
.map(([name, f]) => ({ name, height: f.shape[0], width: f.shape[1] }));
|
| 157 |
+
|
| 158 |
+
result.datasetInfo = {
|
| 159 |
+
...result.datasetInfo,
|
| 160 |
+
robot_type: rawInfo.robot_type ?? null,
|
| 161 |
+
codebase_version: rawInfo.codebase_version,
|
| 162 |
+
total_tasks: rawInfo.total_tasks ?? 0,
|
| 163 |
+
dataset_size_mb: Math.round(((rawInfo.data_files_size_in_mb ?? 0) + (rawInfo.video_files_size_in_mb ?? 0)) * 10) / 10,
|
| 164 |
+
cameras,
|
| 165 |
+
};
|
| 166 |
+
|
| 167 |
return result;
|
| 168 |
} catch (err) {
|
| 169 |
console.error("Error loading episode data:", err);
|
|
|
|
| 238 |
): Promise<EpisodeData> {
|
| 239 |
const episode_chunk = Math.floor(0 / 1000);
|
| 240 |
|
| 241 |
+
const datasetInfo: DatasetDisplayInfo = {
|
|
|
|
| 242 |
repoId,
|
| 243 |
total_frames: info.total_frames,
|
| 244 |
total_episodes: info.total_episodes,
|
| 245 |
fps: info.fps,
|
| 246 |
+
robot_type: null,
|
| 247 |
+
codebase_version: version,
|
| 248 |
+
total_tasks: 0,
|
| 249 |
+
dataset_size_mb: 0,
|
| 250 |
+
cameras: [],
|
| 251 |
};
|
| 252 |
|
| 253 |
// Generate list of episodes
|
|
|
|
| 501 |
episodeId,
|
| 502 |
videosInfo,
|
| 503 |
chartDataGroups,
|
| 504 |
+
flatChartData: chartData,
|
| 505 |
episodes,
|
| 506 |
ignoredColumns,
|
| 507 |
duration,
|
|
|
|
| 516 |
info: DatasetMetadata,
|
| 517 |
episodeId: number,
|
| 518 |
): Promise<EpisodeData> {
|
| 519 |
+
const datasetInfo: DatasetDisplayInfo = {
|
|
|
|
| 520 |
repoId,
|
| 521 |
total_frames: info.total_frames,
|
| 522 |
total_episodes: info.total_episodes,
|
| 523 |
fps: info.fps,
|
| 524 |
+
robot_type: null,
|
| 525 |
+
codebase_version: version,
|
| 526 |
+
total_tasks: 0,
|
| 527 |
+
dataset_size_mb: 0,
|
| 528 |
+
cameras: [],
|
| 529 |
};
|
| 530 |
|
|
|
|
| 531 |
const episodes = Array.from({ length: info.total_episodes }, (_, i) => i);
|
| 532 |
|
| 533 |
// Load episode metadata to get timestamps for episode 0
|
|
|
|
| 537 |
const videosInfo = extractVideoInfoV3WithSegmentation(repoId, version, info, episodeMetadata);
|
| 538 |
|
| 539 |
// Load episode data for charts
|
| 540 |
+
const { chartDataGroups, flatChartData, ignoredColumns, task } = await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
|
| 541 |
|
|
|
|
| 542 |
const duration = episodeMetadata.length ? episodeMetadata.length / info.fps :
|
| 543 |
(episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp);
|
| 544 |
+
|
| 545 |
return {
|
| 546 |
datasetInfo,
|
| 547 |
episodeId,
|
| 548 |
videosInfo,
|
| 549 |
chartDataGroups,
|
| 550 |
+
flatChartData,
|
| 551 |
episodes,
|
| 552 |
ignoredColumns,
|
| 553 |
duration,
|
|
|
|
| 561 |
version: string,
|
| 562 |
info: DatasetMetadata,
|
| 563 |
episodeMetadata: EpisodeMetadataV3,
|
| 564 |
+
): Promise<{ chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[]; task?: string }> {
|
| 565 |
// Build data file path using chunk and file indices
|
| 566 |
const dataChunkIndex = episodeMetadata.data_chunk_index || 0;
|
| 567 |
const dataFileIndex = episodeMetadata.data_file_index || 0;
|
|
|
|
| 591 |
const episodeData = fullData.slice(localFromIndex, localToIndex);
|
| 592 |
|
| 593 |
if (episodeData.length === 0) {
|
| 594 |
+
return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
|
| 595 |
}
|
| 596 |
|
| 597 |
// Convert to the same format as v2.x for compatibility with existing chart code
|
| 598 |
+
const { chartDataGroups, flatChartData, ignoredColumns } = processEpisodeDataForCharts(episodeData, info, episodeMetadata);
|
| 599 |
|
| 600 |
// First check for language_instruction fields in the data (preferred)
|
| 601 |
let task: string | undefined;
|
|
|
|
| 651 |
}
|
| 652 |
}
|
| 653 |
|
| 654 |
+
return { chartDataGroups, flatChartData, ignoredColumns, task };
|
| 655 |
} catch {
|
| 656 |
+
return { chartDataGroups: [], flatChartData: [], ignoredColumns: [], task: undefined };
|
| 657 |
}
|
| 658 |
}
|
| 659 |
|
|
|
|
| 662 |
episodeData: Record<string, unknown>[],
|
| 663 |
info: DatasetMetadata,
|
| 664 |
episodeMetadata?: EpisodeMetadataV3,
|
| 665 |
+
): { chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[]; ignoredColumns: string[] } {
|
| 666 |
|
| 667 |
// Get numeric column features
|
| 668 |
const columnNames = Object.entries(info.features)
|
|
|
|
| 919 |
chartData.map((row) => groupRowBySuffix(pick(row, [...group, "timestamp"])))
|
| 920 |
);
|
| 921 |
|
| 922 |
+
return { chartDataGroups, flatChartData: chartData, ignoredColumns };
|
| 923 |
}
|
| 924 |
|
| 925 |
|
|
|
|
| 1119 |
|
| 1120 |
|
| 1121 |
|
| 1122 |
+
// ─── Stats computation ───────────────────────────────────────────
|
| 1123 |
+
|
| 1124 |
+
/**
|
| 1125 |
+
* Compute per-column min/max values from the current episode's chart data.
|
| 1126 |
+
*/
|
| 1127 |
+
export function computeColumnMinMax(chartDataGroups: ChartRow[][]): ColumnMinMax[] {
|
| 1128 |
+
const stats: Record<string, { min: number; max: number }> = {};
|
| 1129 |
+
|
| 1130 |
+
for (const group of chartDataGroups) {
|
| 1131 |
+
for (const row of group) {
|
| 1132 |
+
for (const [key, value] of Object.entries(row)) {
|
| 1133 |
+
if (key === "timestamp") continue;
|
| 1134 |
+
if (typeof value === "number" && isFinite(value)) {
|
| 1135 |
+
if (!stats[key]) {
|
| 1136 |
+
stats[key] = { min: value, max: value };
|
| 1137 |
+
} else {
|
| 1138 |
+
if (value < stats[key].min) stats[key].min = value;
|
| 1139 |
+
if (value > stats[key].max) stats[key].max = value;
|
| 1140 |
+
}
|
| 1141 |
+
} else if (typeof value === "object" && value !== null) {
|
| 1142 |
+
// Nested group like { joint_0: 1.2, joint_1: 3.4 }
|
| 1143 |
+
for (const [subKey, subVal] of Object.entries(value)) {
|
| 1144 |
+
const fullKey = `${key} | ${subKey}`;
|
| 1145 |
+
if (typeof subVal === "number" && isFinite(subVal)) {
|
| 1146 |
+
if (!stats[fullKey]) {
|
| 1147 |
+
stats[fullKey] = { min: subVal, max: subVal };
|
| 1148 |
+
} else {
|
| 1149 |
+
if (subVal < stats[fullKey].min) stats[fullKey].min = subVal;
|
| 1150 |
+
if (subVal > stats[fullKey].max) stats[fullKey].max = subVal;
|
| 1151 |
+
}
|
| 1152 |
+
}
|
| 1153 |
+
}
|
| 1154 |
+
}
|
| 1155 |
+
}
|
| 1156 |
+
}
|
| 1157 |
+
}
|
| 1158 |
+
|
| 1159 |
+
return Object.entries(stats).map(([column, { min, max }]) => ({
|
| 1160 |
+
column,
|
| 1161 |
+
min: Math.round(min * 1000) / 1000,
|
| 1162 |
+
max: Math.round(max * 1000) / 1000,
|
| 1163 |
+
}));
|
| 1164 |
+
}
|
| 1165 |
+
|
| 1166 |
+
/**
|
| 1167 |
+
* Load all episode lengths from the episodes metadata parquet files (v3.0).
|
| 1168 |
+
* Returns min/max/mean/median/std and a histogram, or null if unavailable.
|
| 1169 |
+
*/
|
| 1170 |
+
export async function loadAllEpisodeLengthsV3(
|
| 1171 |
+
repoId: string,
|
| 1172 |
+
version: string,
|
| 1173 |
+
fps: number,
|
| 1174 |
+
): Promise<EpisodeLengthStats | null> {
|
| 1175 |
+
try {
|
| 1176 |
+
const allEpisodes: { index: number; length: number }[] = [];
|
| 1177 |
+
let fileIndex = 0;
|
| 1178 |
+
const chunkIndex = 0;
|
| 1179 |
+
|
| 1180 |
+
while (true) {
|
| 1181 |
+
const path = `meta/episodes/chunk-${chunkIndex.toString().padStart(3, "0")}/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1182 |
+
const url = buildVersionedUrl(repoId, version, path);
|
| 1183 |
+
try {
|
| 1184 |
+
const buf = await fetchParquetFile(url);
|
| 1185 |
+
const rows = await readParquetAsObjects(buf, []);
|
| 1186 |
+
if (rows.length === 0 && fileIndex > 0) break;
|
| 1187 |
+
for (const row of rows) {
|
| 1188 |
+
const parsed = parseEpisodeRowSimple(row);
|
| 1189 |
+
allEpisodes.push({ index: parsed.episode_index, length: parsed.length });
|
| 1190 |
+
}
|
| 1191 |
+
fileIndex++;
|
| 1192 |
+
} catch {
|
| 1193 |
+
break;
|
| 1194 |
+
}
|
| 1195 |
+
}
|
| 1196 |
+
|
| 1197 |
+
if (allEpisodes.length === 0) return null;
|
| 1198 |
+
|
| 1199 |
+
const withSeconds = allEpisodes.map((ep) => ({
|
| 1200 |
+
episodeIndex: ep.index,
|
| 1201 |
+
frames: ep.length,
|
| 1202 |
+
lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
|
| 1203 |
+
}));
|
| 1204 |
+
|
| 1205 |
+
const sortedByLength = [...withSeconds].sort((a, b) => a.lengthSeconds - b.lengthSeconds);
|
| 1206 |
+
const shortestEpisodes = sortedByLength.slice(0, 5);
|
| 1207 |
+
const longestEpisodes = sortedByLength.slice(-5).reverse();
|
| 1208 |
+
|
| 1209 |
+
const lengths = withSeconds.map((e) => e.lengthSeconds);
|
| 1210 |
+
const sum = lengths.reduce((a, b) => a + b, 0);
|
| 1211 |
+
const mean = Math.round((sum / lengths.length) * 100) / 100;
|
| 1212 |
+
|
| 1213 |
+
const sorted = [...lengths].sort((a, b) => a - b);
|
| 1214 |
+
const mid = Math.floor(sorted.length / 2);
|
| 1215 |
+
const median = sorted.length % 2 === 0
|
| 1216 |
+
? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100
|
| 1217 |
+
: sorted[mid];
|
| 1218 |
+
|
| 1219 |
+
const variance = lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length;
|
| 1220 |
+
const std = Math.round(Math.sqrt(variance) * 100) / 100;
|
| 1221 |
+
|
| 1222 |
+
// Build histogram
|
| 1223 |
+
const histMin = Math.min(...lengths);
|
| 1224 |
+
const histMax = Math.max(...lengths);
|
| 1225 |
+
|
| 1226 |
+
if (histMax === histMin) {
|
| 1227 |
+
return {
|
| 1228 |
+
shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
|
| 1229 |
+
meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
|
| 1230 |
+
episodeLengthHistogram: [{ binLabel: `${histMin.toFixed(1)}s`, count: lengths.length }],
|
| 1231 |
+
};
|
| 1232 |
+
}
|
| 1233 |
+
|
| 1234 |
+
const p1 = sorted[Math.floor(sorted.length * 0.01)];
|
| 1235 |
+
const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
|
| 1236 |
+
const range = (p99 - p1) || 1;
|
| 1237 |
+
|
| 1238 |
+
const targetBins = Math.max(10, Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)));
|
| 1239 |
+
const rawBinWidth = range / targetBins;
|
| 1240 |
+
const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
|
| 1241 |
+
const niceSteps = [1, 2, 2.5, 5, 10];
|
| 1242 |
+
const niceBinWidth = niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ?? rawBinWidth;
|
| 1243 |
+
|
| 1244 |
+
const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
|
| 1245 |
+
const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
|
| 1246 |
+
const actualBinCount = Math.max(1, Math.round((niceMax - niceMin) / niceBinWidth));
|
| 1247 |
+
const bins = Array.from({ length: actualBinCount }, () => 0);
|
| 1248 |
+
|
| 1249 |
+
for (const len of lengths) {
|
| 1250 |
+
let binIdx = Math.floor((len - niceMin) / niceBinWidth);
|
| 1251 |
+
if (binIdx < 0) binIdx = 0;
|
| 1252 |
+
if (binIdx >= actualBinCount) binIdx = actualBinCount - 1;
|
| 1253 |
+
bins[binIdx]++;
|
| 1254 |
+
}
|
| 1255 |
+
|
| 1256 |
+
const histogram = bins.map((count, i) => {
|
| 1257 |
+
const lo = niceMin + i * niceBinWidth;
|
| 1258 |
+
const hi = lo + niceBinWidth;
|
| 1259 |
+
return { binLabel: `${lo.toFixed(1)}–${hi.toFixed(1)}s`, count };
|
| 1260 |
+
});
|
| 1261 |
+
|
| 1262 |
+
return {
|
| 1263 |
+
shortestEpisodes, longestEpisodes, allEpisodeLengths: withSeconds,
|
| 1264 |
+
meanEpisodeLength: mean, medianEpisodeLength: median, stdEpisodeLength: std,
|
| 1265 |
+
episodeLengthHistogram: histogram,
|
| 1266 |
+
};
|
| 1267 |
+
} catch {
|
| 1268 |
+
return null;
|
| 1269 |
+
}
|
| 1270 |
+
}
|
| 1271 |
+
|
| 1272 |
+
/**
|
| 1273 |
+
* Load video frame info for all episodes across all cameras.
|
| 1274 |
+
* Returns camera names + a map of camera → EpisodeFrameInfo[].
|
| 1275 |
+
*/
|
| 1276 |
+
export async function loadAllEpisodeFrameInfo(
|
| 1277 |
+
repoId: string,
|
| 1278 |
+
version: string,
|
| 1279 |
+
info: DatasetMetadata,
|
| 1280 |
+
): Promise<EpisodeFramesData> {
|
| 1281 |
+
const videoFeatures = Object.entries(info.features).filter(([, f]) => f.dtype === "video");
|
| 1282 |
+
if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
|
| 1283 |
+
|
| 1284 |
+
const cameras = videoFeatures.map(([key]) => key);
|
| 1285 |
+
const framesByCamera: Record<string, EpisodeFrameInfo[]> = {};
|
| 1286 |
+
for (const cam of cameras) framesByCamera[cam] = [];
|
| 1287 |
+
|
| 1288 |
+
if (version === "v3.0") {
|
| 1289 |
+
let fileIndex = 0;
|
| 1290 |
+
while (true) {
|
| 1291 |
+
const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1292 |
+
try {
|
| 1293 |
+
const buf = await fetchParquetFile(buildVersionedUrl(repoId, version, path));
|
| 1294 |
+
const rows = await readParquetAsObjects(buf, []);
|
| 1295 |
+
if (rows.length === 0 && fileIndex > 0) break;
|
| 1296 |
+
for (const row of rows) {
|
| 1297 |
+
const epIdx = Number(row["episode_index"] ?? 0);
|
| 1298 |
+
for (const cam of cameras) {
|
| 1299 |
+
const cIdx = Number(row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0);
|
| 1300 |
+
const fIdx = Number(row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0);
|
| 1301 |
+
const fromTs = Number(row[`videos/${cam}/from_timestamp`] ?? row["video_from_timestamp"] ?? 0);
|
| 1302 |
+
const toTs = Number(row[`videos/${cam}/to_timestamp`] ?? row["video_to_timestamp"] ?? 30);
|
| 1303 |
+
const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
|
| 1304 |
+
framesByCamera[cam].push({
|
| 1305 |
+
episodeIndex: epIdx,
|
| 1306 |
+
videoUrl: buildVersionedUrl(repoId, version, videoPath),
|
| 1307 |
+
firstFrameTime: fromTs,
|
| 1308 |
+
lastFrameTime: Math.max(0, toTs - 0.05),
|
| 1309 |
+
});
|
| 1310 |
+
}
|
| 1311 |
+
}
|
| 1312 |
+
fileIndex++;
|
| 1313 |
+
} catch {
|
| 1314 |
+
break;
|
| 1315 |
+
}
|
| 1316 |
+
}
|
| 1317 |
+
return { cameras, framesByCamera };
|
| 1318 |
+
}
|
| 1319 |
+
|
| 1320 |
+
// v2.x — construct URLs from template
|
| 1321 |
+
for (let i = 0; i < info.total_episodes; i++) {
|
| 1322 |
+
const chunk = Math.floor(i / (info.chunks_size || 1000));
|
| 1323 |
+
for (const cam of cameras) {
|
| 1324 |
+
const videoPath = formatStringWithVars(info.video_path, {
|
| 1325 |
+
video_key: cam,
|
| 1326 |
+
episode_chunk: chunk.toString().padStart(3, "0"),
|
| 1327 |
+
episode_index: i.toString().padStart(6, "0"),
|
| 1328 |
+
});
|
| 1329 |
+
framesByCamera[cam].push({
|
| 1330 |
+
episodeIndex: i,
|
| 1331 |
+
videoUrl: buildVersionedUrl(repoId, version, videoPath),
|
| 1332 |
+
firstFrameTime: 0,
|
| 1333 |
+
lastFrameTime: null,
|
| 1334 |
+
});
|
| 1335 |
+
}
|
| 1336 |
+
}
|
| 1337 |
+
return { cameras, framesByCamera };
|
| 1338 |
+
}
|
| 1339 |
+
|
| 1340 |
// Safe wrapper for UI error display
|
| 1341 |
export async function getEpisodeDataSafe(
|
| 1342 |
org: string,
|
src/components/overview-panel.tsx
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef, useCallback } from "react";
|
| 4 |
+
import type { EpisodeFrameInfo, EpisodeFramesData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 5 |
+
|
| 6 |
+
const PAGE_SIZE = 48;
|
| 7 |
+
|
| 8 |
+
function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast: boolean }) {
|
| 9 |
+
const containerRef = useRef<HTMLDivElement>(null);
|
| 10 |
+
const videoRef = useRef<HTMLVideoElement>(null);
|
| 11 |
+
const [inView, setInView] = useState(false);
|
| 12 |
+
|
| 13 |
+
useEffect(() => {
|
| 14 |
+
const el = containerRef.current;
|
| 15 |
+
if (!el) return;
|
| 16 |
+
const obs = new IntersectionObserver(
|
| 17 |
+
([e]) => { if (e.isIntersecting) { setInView(true); obs.disconnect(); } },
|
| 18 |
+
{ rootMargin: "200px" },
|
| 19 |
+
);
|
| 20 |
+
obs.observe(el);
|
| 21 |
+
return () => obs.disconnect();
|
| 22 |
+
}, []);
|
| 23 |
+
|
| 24 |
+
useEffect(() => {
|
| 25 |
+
const video = videoRef.current;
|
| 26 |
+
if (!video || !inView) return;
|
| 27 |
+
|
| 28 |
+
const seek = () => {
|
| 29 |
+
if (showLast) {
|
| 30 |
+
video.currentTime = info.lastFrameTime ?? Math.max(0, video.duration - 0.05);
|
| 31 |
+
} else {
|
| 32 |
+
video.currentTime = info.firstFrameTime;
|
| 33 |
+
}
|
| 34 |
+
};
|
| 35 |
+
|
| 36 |
+
if (video.readyState >= 1) {
|
| 37 |
+
seek();
|
| 38 |
+
} else {
|
| 39 |
+
video.addEventListener("loadedmetadata", seek, { once: true });
|
| 40 |
+
return () => video.removeEventListener("loadedmetadata", seek);
|
| 41 |
+
}
|
| 42 |
+
}, [inView, showLast, info]);
|
| 43 |
+
|
| 44 |
+
return (
|
| 45 |
+
<div ref={containerRef} className="flex flex-col items-center">
|
| 46 |
+
<div className="w-full aspect-video bg-slate-800 rounded overflow-hidden">
|
| 47 |
+
{inView ? (
|
| 48 |
+
<video
|
| 49 |
+
ref={videoRef}
|
| 50 |
+
src={info.videoUrl}
|
| 51 |
+
preload="metadata"
|
| 52 |
+
muted
|
| 53 |
+
className="w-full h-full object-cover"
|
| 54 |
+
/>
|
| 55 |
+
) : (
|
| 56 |
+
<div className="w-full h-full animate-pulse bg-slate-700" />
|
| 57 |
+
)}
|
| 58 |
+
</div>
|
| 59 |
+
<p className="text-xs text-slate-400 mt-1 tabular-nums">ep {info.episodeIndex}</p>
|
| 60 |
+
</div>
|
| 61 |
+
);
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
interface OverviewPanelProps {
|
| 65 |
+
data: EpisodeFramesData | null;
|
| 66 |
+
loading: boolean;
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
export default function OverviewPanel({ data, loading }: OverviewPanelProps) {
|
| 70 |
+
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
| 71 |
+
const [showLast, setShowLast] = useState(false);
|
| 72 |
+
const [page, setPage] = useState(0);
|
| 73 |
+
|
| 74 |
+
// Auto-select first camera when data arrives
|
| 75 |
+
useEffect(() => {
|
| 76 |
+
if (data && data.cameras.length > 0 && !selectedCamera) {
|
| 77 |
+
setSelectedCamera(data.cameras[0]);
|
| 78 |
+
}
|
| 79 |
+
}, [data, selectedCamera]);
|
| 80 |
+
|
| 81 |
+
const handleCameraChange = useCallback((e: React.ChangeEvent<HTMLSelectElement>) => {
|
| 82 |
+
setSelectedCamera(e.target.value);
|
| 83 |
+
setPage(0);
|
| 84 |
+
}, []);
|
| 85 |
+
|
| 86 |
+
if (loading || !data) {
|
| 87 |
+
return (
|
| 88 |
+
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
| 89 |
+
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 90 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 91 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 92 |
+
</svg>
|
| 93 |
+
Loading episode frames…
|
| 94 |
+
</div>
|
| 95 |
+
);
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
const frames = data.framesByCamera[selectedCamera] ?? [];
|
| 99 |
+
|
| 100 |
+
if (frames.length === 0) {
|
| 101 |
+
return <p className="text-slate-500 italic py-8 text-center">No episode frames available.</p>;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
const totalPages = Math.ceil(frames.length / PAGE_SIZE);
|
| 105 |
+
const pageFrames = frames.slice(page * PAGE_SIZE, (page + 1) * PAGE_SIZE);
|
| 106 |
+
|
| 107 |
+
return (
|
| 108 |
+
<div className="max-w-7xl mx-auto py-6 space-y-5">
|
| 109 |
+
<p className="text-sm text-slate-500">
|
| 110 |
+
Use first/last frame views to spot episodes with bad end states, or other anomalies — and to visualize diversity across the dataset.
|
| 111 |
+
</p>
|
| 112 |
+
|
| 113 |
+
{/* Controls row */}
|
| 114 |
+
<div className="flex items-center justify-between flex-wrap gap-4">
|
| 115 |
+
<div className="flex items-center gap-5">
|
| 116 |
+
{/* Camera selector */}
|
| 117 |
+
{data.cameras.length > 1 && (
|
| 118 |
+
<select
|
| 119 |
+
value={selectedCamera}
|
| 120 |
+
onChange={handleCameraChange}
|
| 121 |
+
className="bg-slate-800 text-slate-200 text-sm rounded px-3 py-1.5 border border-slate-600 focus:outline-none focus:border-orange-500"
|
| 122 |
+
>
|
| 123 |
+
{data.cameras.map((cam) => (
|
| 124 |
+
<option key={cam} value={cam}>{cam}</option>
|
| 125 |
+
))}
|
| 126 |
+
</select>
|
| 127 |
+
)}
|
| 128 |
+
|
| 129 |
+
{/* First / Last toggle */}
|
| 130 |
+
<div className="flex items-center gap-3">
|
| 131 |
+
<span className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
|
| 132 |
+
First Frame
|
| 133 |
+
</span>
|
| 134 |
+
<button
|
| 135 |
+
onClick={() => setShowLast((v) => !v)}
|
| 136 |
+
className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${showLast ? "bg-orange-500" : "bg-slate-600"}`}
|
| 137 |
+
aria-label="Toggle first/last frame"
|
| 138 |
+
>
|
| 139 |
+
<span
|
| 140 |
+
className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
| 141 |
+
/>
|
| 142 |
+
</button>
|
| 143 |
+
<span className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}>
|
| 144 |
+
Last Frame
|
| 145 |
+
</span>
|
| 146 |
+
</div>
|
| 147 |
+
</div>
|
| 148 |
+
|
| 149 |
+
{/* Pagination */}
|
| 150 |
+
{totalPages > 1 && (
|
| 151 |
+
<div className="flex items-center gap-2 text-sm text-slate-300">
|
| 152 |
+
<button
|
| 153 |
+
disabled={page === 0}
|
| 154 |
+
onClick={() => setPage((p) => p - 1)}
|
| 155 |
+
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 156 |
+
>
|
| 157 |
+
← Prev
|
| 158 |
+
</button>
|
| 159 |
+
<span className="tabular-nums">
|
| 160 |
+
{page + 1} / {totalPages}
|
| 161 |
+
</span>
|
| 162 |
+
<button
|
| 163 |
+
disabled={page === totalPages - 1}
|
| 164 |
+
onClick={() => setPage((p) => p + 1)}
|
| 165 |
+
className="px-2 py-1 rounded bg-slate-800 hover:bg-slate-700 disabled:opacity-30 disabled:cursor-not-allowed"
|
| 166 |
+
>
|
| 167 |
+
Next →
|
| 168 |
+
</button>
|
| 169 |
+
</div>
|
| 170 |
+
)}
|
| 171 |
+
</div>
|
| 172 |
+
|
| 173 |
+
{/* Adaptive grid — only current page's thumbnails are mounted */}
|
| 174 |
+
<div className="grid gap-3" style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}>
|
| 175 |
+
{pageFrames.map((info) => (
|
| 176 |
+
<FrameThumbnail key={`${selectedCamera}-${info.episodeIndex}`} info={info} showLast={showLast} />
|
| 177 |
+
))}
|
| 178 |
+
</div>
|
| 179 |
+
</div>
|
| 180 |
+
);
|
| 181 |
+
}
|
src/components/side-nav.tsx
CHANGED
|
@@ -24,46 +24,29 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|
| 24 |
prevPage,
|
| 25 |
nextPage,
|
| 26 |
}) => {
|
| 27 |
-
|
| 28 |
-
const
|
| 29 |
-
|
| 30 |
-
const sidebarRef = React.useRef<HTMLDivElement>(null);
|
| 31 |
-
|
| 32 |
-
React.useEffect(() => {
|
| 33 |
-
if (!sidebarVisible) return;
|
| 34 |
-
function handleClickOutside(event: MouseEvent) {
|
| 35 |
-
// If click is outside the sidebar nav
|
| 36 |
-
if (
|
| 37 |
-
sidebarRef.current &&
|
| 38 |
-
!sidebarRef.current.contains(event.target as Node)
|
| 39 |
-
) {
|
| 40 |
-
setTimeout(() => setSidebarVisible(false), 500);
|
| 41 |
-
}
|
| 42 |
-
}
|
| 43 |
-
document.addEventListener("mousedown", handleClickOutside);
|
| 44 |
-
return () => {
|
| 45 |
-
document.removeEventListener("mousedown", handleClickOutside);
|
| 46 |
-
};
|
| 47 |
-
}, [sidebarVisible]);
|
| 48 |
|
| 49 |
return (
|
| 50 |
-
<div className="flex z-10
|
|
|
|
| 51 |
<nav
|
| 52 |
-
className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words
|
| 53 |
-
|
| 54 |
-
}`}
|
| 55 |
aria-label="Sidebar navigation"
|
| 56 |
>
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
<li>
|
| 60 |
-
<li>
|
|
|
|
| 61 |
</ul>
|
| 62 |
|
| 63 |
-
<p>Episodes:</p>
|
| 64 |
|
| 65 |
-
{/*
|
| 66 |
-
<div className="ml-2
|
| 67 |
<ul>
|
| 68 |
{paginatedEpisodes.map((episode) => (
|
| 69 |
<li key={episode} className="mt-0.5 font-mono text-sm">
|
|
@@ -106,13 +89,14 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|
| 106 |
)}
|
| 107 |
</div>
|
| 108 |
</nav>
|
| 109 |
-
|
|
|
|
| 110 |
<button
|
| 111 |
-
className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0"
|
| 112 |
-
onClick={
|
| 113 |
title="Toggle sidebar"
|
| 114 |
>
|
| 115 |
-
<div className="h-10 w-2 rounded-full bg-slate-500"
|
| 116 |
</button>
|
| 117 |
</div>
|
| 118 |
);
|
|
|
|
| 24 |
prevPage,
|
| 25 |
nextPage,
|
| 26 |
}) => {
|
| 27 |
+
// On mobile, allow toggling; on desktop the sidebar is always visible
|
| 28 |
+
const [mobileVisible, setMobileVisible] = React.useState(false);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
return (
|
| 31 |
+
<div className="flex z-10 shrink-0">
|
| 32 |
+
{/* Sidebar panel — always visible on md+, togglable on mobile */}
|
| 33 |
<nav
|
| 34 |
+
className={`shrink-0 overflow-y-auto bg-slate-900 p-5 break-words w-60 ${
|
| 35 |
+
mobileVisible ? "block" : "hidden"
|
| 36 |
+
} md:block`}
|
| 37 |
aria-label="Sidebar navigation"
|
| 38 |
>
|
| 39 |
+
{/* Basic dataset info */}
|
| 40 |
+
<ul className="text-sm text-slate-300 space-y-0.5">
|
| 41 |
+
<li>Frames: {datasetInfo.total_frames.toLocaleString()}</li>
|
| 42 |
+
<li>Episodes: {datasetInfo.total_episodes.toLocaleString()}</li>
|
| 43 |
+
<li>FPS: {datasetInfo.fps}</li>
|
| 44 |
</ul>
|
| 45 |
|
| 46 |
+
<p className="mt-4 text-sm font-semibold text-slate-200">Episodes:</p>
|
| 47 |
|
| 48 |
+
{/* Episodes list */}
|
| 49 |
+
<div className="ml-2 mt-1">
|
| 50 |
<ul>
|
| 51 |
{paginatedEpisodes.map((episode) => (
|
| 52 |
<li key={episode} className="mt-0.5 font-mono text-sm">
|
|
|
|
| 89 |
)}
|
| 90 |
</div>
|
| 91 |
</nav>
|
| 92 |
+
|
| 93 |
+
{/* Mobile toggle button */}
|
| 94 |
<button
|
| 95 |
+
className="mx-1 flex items-center opacity-50 hover:opacity-100 focus:outline-none focus:ring-0 md:hidden"
|
| 96 |
+
onClick={() => setMobileVisible((prev) => !prev)}
|
| 97 |
title="Toggle sidebar"
|
| 98 |
>
|
| 99 |
+
<div className="h-10 w-2 rounded-full bg-slate-500" />
|
| 100 |
</button>
|
| 101 |
</div>
|
| 102 |
);
|
src/components/stats-panel.tsx
ADDED
|
@@ -0,0 +1,335 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useMemo, useCallback } from "react";
|
| 4 |
+
import type {
|
| 5 |
+
DatasetDisplayInfo,
|
| 6 |
+
ColumnMinMax,
|
| 7 |
+
EpisodeLengthStats,
|
| 8 |
+
EpisodeLengthInfo,
|
| 9 |
+
CameraInfo,
|
| 10 |
+
} from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 11 |
+
|
| 12 |
+
interface StatsPanelProps {
|
| 13 |
+
datasetInfo: DatasetDisplayInfo;
|
| 14 |
+
episodeId: number;
|
| 15 |
+
columnMinMax: ColumnMinMax[] | null;
|
| 16 |
+
episodeLengthStats: EpisodeLengthStats | null;
|
| 17 |
+
loading: boolean;
|
| 18 |
+
}
|
| 19 |
+
|
| 20 |
+
function formatTotalTime(totalFrames: number, fps: number): string {
|
| 21 |
+
const totalSec = totalFrames / fps;
|
| 22 |
+
const hours = Math.floor(totalSec / 3600);
|
| 23 |
+
const minutes = Math.floor((totalSec % 3600) / 60);
|
| 24 |
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
| 25 |
+
return `${minutes}m`;
|
| 26 |
+
}
|
| 27 |
+
|
| 28 |
+
/** SVG bar chart for the episode-length histogram */
|
| 29 |
+
const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number }[] }> = ({ data }) => {
|
| 30 |
+
if (data.length === 0) return null;
|
| 31 |
+
const maxCount = Math.max(...data.map((d) => d.count));
|
| 32 |
+
if (maxCount === 0) return null;
|
| 33 |
+
|
| 34 |
+
const totalWidth = 560;
|
| 35 |
+
const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
|
| 36 |
+
const barWidth = Math.max(4, Math.floor((totalWidth - gap * data.length) / data.length));
|
| 37 |
+
const chartHeight = 150;
|
| 38 |
+
const labelHeight = 30;
|
| 39 |
+
const topPad = 16;
|
| 40 |
+
const svgWidth = data.length * (barWidth + gap);
|
| 41 |
+
const labelStep = Math.max(1, Math.ceil(data.length / 10));
|
| 42 |
+
|
| 43 |
+
return (
|
| 44 |
+
<div className="overflow-x-auto">
|
| 45 |
+
<svg width={svgWidth} height={topPad + chartHeight + labelHeight} className="block" aria-label="Episode length distribution histogram">
|
| 46 |
+
{data.map((bin, i) => {
|
| 47 |
+
const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
|
| 48 |
+
const x = i * (barWidth + gap);
|
| 49 |
+
const y = topPad + chartHeight - barH;
|
| 50 |
+
return (
|
| 51 |
+
<g key={i}>
|
| 52 |
+
<title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
|
| 53 |
+
<rect x={x} y={y} width={barWidth} height={barH} className="fill-orange-500/80 hover:fill-orange-400 transition-colors" rx={Math.min(2, barWidth / 4)} />
|
| 54 |
+
{bin.count > 0 && barWidth >= 8 && (
|
| 55 |
+
<text x={x + barWidth / 2} y={y - 3} textAnchor="middle" className="fill-slate-400" fontSize={Math.min(10, barWidth - 1)}>
|
| 56 |
+
{bin.count}
|
| 57 |
+
</text>
|
| 58 |
+
)}
|
| 59 |
+
</g>
|
| 60 |
+
);
|
| 61 |
+
})}
|
| 62 |
+
{data.map((bin, idx) => {
|
| 63 |
+
const isFirst = idx === 0;
|
| 64 |
+
const isLast = idx === data.length - 1;
|
| 65 |
+
if (!isFirst && !isLast && idx % labelStep !== 0) return null;
|
| 66 |
+
const label = bin.binLabel.split("–")[0];
|
| 67 |
+
return (
|
| 68 |
+
<text key={idx} x={idx * (barWidth + gap) + barWidth / 2} y={topPad + chartHeight + 14} textAnchor="middle" className="fill-slate-400" fontSize={9}>
|
| 69 |
+
{label}s
|
| 70 |
+
</text>
|
| 71 |
+
);
|
| 72 |
+
})}
|
| 73 |
+
</svg>
|
| 74 |
+
</div>
|
| 75 |
+
);
|
| 76 |
+
};
|
| 77 |
+
|
| 78 |
+
const Card: React.FC<{ label: string; value: string | number }> = ({ label, value }) => (
|
| 79 |
+
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
|
| 80 |
+
<p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
|
| 81 |
+
<p className="text-xl font-bold tabular-nums mt-1">{value}</p>
|
| 82 |
+
</div>
|
| 83 |
+
);
|
| 84 |
+
|
| 85 |
+
function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
|
| 86 |
+
const globalMin = useMemo(() => Math.min(...episodes.map((e) => e.lengthSeconds)), [episodes]);
|
| 87 |
+
const globalMax = useMemo(() => Math.max(...episodes.map((e) => e.lengthSeconds)), [episodes]);
|
| 88 |
+
|
| 89 |
+
const [rangeMin, setRangeMin] = useState(globalMin);
|
| 90 |
+
const [rangeMax, setRangeMax] = useState(globalMax);
|
| 91 |
+
const [showOutside, setShowOutside] = useState(false);
|
| 92 |
+
const [copied, setCopied] = useState(false);
|
| 93 |
+
|
| 94 |
+
const filtered = useMemo(() => {
|
| 95 |
+
const inRange = episodes.filter((e) => e.lengthSeconds >= rangeMin && e.lengthSeconds <= rangeMax);
|
| 96 |
+
const outRange = episodes.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax);
|
| 97 |
+
return showOutside ? outRange : inRange;
|
| 98 |
+
}, [episodes, rangeMin, rangeMax, showOutside]);
|
| 99 |
+
|
| 100 |
+
const ids = useMemo(() => filtered.map((e) => e.episodeIndex).sort((a, b) => a - b), [filtered]);
|
| 101 |
+
|
| 102 |
+
const handleCopy = useCallback(() => {
|
| 103 |
+
navigator.clipboard.writeText(ids.join(", "));
|
| 104 |
+
setCopied(true);
|
| 105 |
+
setTimeout(() => setCopied(false), 1500);
|
| 106 |
+
}, [ids]);
|
| 107 |
+
|
| 108 |
+
const step = Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) || 0.01;
|
| 109 |
+
|
| 110 |
+
return (
|
| 111 |
+
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 112 |
+
<h3 className="text-sm font-semibold text-slate-200">Episode Length Filter</h3>
|
| 113 |
+
|
| 114 |
+
{/* Range slider row */}
|
| 115 |
+
<div className="space-y-2">
|
| 116 |
+
<div className="flex items-center justify-between text-xs text-slate-400">
|
| 117 |
+
<span className="tabular-nums">{rangeMin.toFixed(1)}s</span>
|
| 118 |
+
<span className="tabular-nums">{rangeMax.toFixed(1)}s</span>
|
| 119 |
+
</div>
|
| 120 |
+
<div className="relative h-5">
|
| 121 |
+
{/* track background */}
|
| 122 |
+
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
|
| 123 |
+
{/* active range highlight */}
|
| 124 |
+
<div
|
| 125 |
+
className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
|
| 126 |
+
style={{
|
| 127 |
+
left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 128 |
+
right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 129 |
+
}}
|
| 130 |
+
/>
|
| 131 |
+
<input
|
| 132 |
+
type="range"
|
| 133 |
+
min={globalMin}
|
| 134 |
+
max={globalMax}
|
| 135 |
+
step={step}
|
| 136 |
+
value={rangeMin}
|
| 137 |
+
onChange={(e) => setRangeMin(Math.min(Number(e.target.value), rangeMax))}
|
| 138 |
+
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
|
| 139 |
+
/>
|
| 140 |
+
<input
|
| 141 |
+
type="range"
|
| 142 |
+
min={globalMin}
|
| 143 |
+
max={globalMax}
|
| 144 |
+
step={step}
|
| 145 |
+
value={rangeMax}
|
| 146 |
+
onChange={(e) => setRangeMax(Math.max(Number(e.target.value), rangeMin))}
|
| 147 |
+
className="absolute inset-0 w-full appearance-none bg-transparent pointer-events-none [&::-webkit-slider-thumb]:pointer-events-auto [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3.5 [&::-webkit-slider-thumb]:h-3.5 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:border-orange-500 [&::-webkit-slider-thumb]:cursor-pointer [&::-moz-range-thumb]:pointer-events-auto [&::-moz-range-thumb]:appearance-none [&::-moz-range-thumb]:w-3.5 [&::-moz-range-thumb]:h-3.5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:bg-white [&::-moz-range-thumb]:border-2 [&::-moz-range-thumb]:border-orange-500 [&::-moz-range-thumb]:cursor-pointer"
|
| 148 |
+
/>
|
| 149 |
+
</div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
{/* Mode selector */}
|
| 153 |
+
<div className="flex items-center gap-3">
|
| 154 |
+
<span className="text-xs text-slate-400">Show:</span>
|
| 155 |
+
<select
|
| 156 |
+
value={showOutside ? "outside" : "inside"}
|
| 157 |
+
onChange={(e) => setShowOutside(e.target.value === "outside")}
|
| 158 |
+
className="bg-slate-900 text-slate-200 text-xs rounded px-2 py-1 border border-slate-600 focus:outline-none focus:border-orange-500"
|
| 159 |
+
>
|
| 160 |
+
<option value="inside">Episodes in range</option>
|
| 161 |
+
<option value="outside">Episodes outside range</option>
|
| 162 |
+
</select>
|
| 163 |
+
<span className="text-xs text-slate-500 tabular-nums ml-auto">{ids.length} episode{ids.length !== 1 ? "s" : ""}</span>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
{/* Results box */}
|
| 167 |
+
<div className="relative bg-slate-900/70 rounded-md border border-slate-700 p-3 max-h-40 overflow-y-auto">
|
| 168 |
+
<button
|
| 169 |
+
onClick={handleCopy}
|
| 170 |
+
className="sticky top-0 float-right ml-2 p-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
|
| 171 |
+
title="Copy to clipboard"
|
| 172 |
+
>
|
| 173 |
+
{copied ? (
|
| 174 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="text-green-400"><polyline points="20 6 9 17 4 12" /></svg>
|
| 175 |
+
) : (
|
| 176 |
+
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" /><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" /></svg>
|
| 177 |
+
)}
|
| 178 |
+
</button>
|
| 179 |
+
<p className="text-sm text-slate-300 tabular-nums leading-relaxed">
|
| 180 |
+
{ids.length > 0 ? ids.join(", ") : <span className="text-slate-500 italic">No episodes match</span>}
|
| 181 |
+
</p>
|
| 182 |
+
</div>
|
| 183 |
+
</div>
|
| 184 |
+
);
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
const StatsPanel: React.FC<StatsPanelProps> = ({
|
| 188 |
+
datasetInfo,
|
| 189 |
+
episodeId,
|
| 190 |
+
columnMinMax,
|
| 191 |
+
episodeLengthStats,
|
| 192 |
+
loading,
|
| 193 |
+
}) => {
|
| 194 |
+
const els = episodeLengthStats;
|
| 195 |
+
|
| 196 |
+
return (
|
| 197 |
+
<div className="max-w-4xl mx-auto py-6 space-y-8">
|
| 198 |
+
<div>
|
| 199 |
+
<h2 className="text-xl text-slate-100"><span className="font-bold">Dataset Statistics:</span> <span className="font-normal text-slate-400">{datasetInfo.repoId}</span></h2>
|
| 200 |
+
</div>
|
| 201 |
+
|
| 202 |
+
{/* Overview cards */}
|
| 203 |
+
<div className="grid grid-cols-3 gap-4">
|
| 204 |
+
<Card label="Robot Type" value={datasetInfo.robot_type ?? "unknown"} />
|
| 205 |
+
<Card label="Dataset Version" value={datasetInfo.codebase_version} />
|
| 206 |
+
<Card label="Tasks" value={datasetInfo.total_tasks} />
|
| 207 |
+
</div>
|
| 208 |
+
|
| 209 |
+
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 210 |
+
<Card label="Total Frames" value={datasetInfo.total_frames.toLocaleString()} />
|
| 211 |
+
<Card label="Total Episodes" value={datasetInfo.total_episodes.toLocaleString()} />
|
| 212 |
+
<Card label="FPS" value={datasetInfo.fps} />
|
| 213 |
+
<Card label="Total Recording Time" value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)} />
|
| 214 |
+
</div>
|
| 215 |
+
|
| 216 |
+
{/* Camera resolutions */}
|
| 217 |
+
{datasetInfo.cameras.length > 0 && (
|
| 218 |
+
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 219 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-3">Camera Resolutions</h3>
|
| 220 |
+
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 221 |
+
{datasetInfo.cameras.map((cam: CameraInfo) => (
|
| 222 |
+
<div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
|
| 223 |
+
<p className="text-xs text-slate-400 mb-1 truncate" title={cam.name}>{cam.name}</p>
|
| 224 |
+
<p className="text-base font-bold tabular-nums">{cam.width}×{cam.height}</p>
|
| 225 |
+
</div>
|
| 226 |
+
))}
|
| 227 |
+
</div>
|
| 228 |
+
</div>
|
| 229 |
+
)}
|
| 230 |
+
|
| 231 |
+
{/* Loading spinner for async stats */}
|
| 232 |
+
{loading && (
|
| 233 |
+
<div className="flex items-center gap-2 text-slate-400 text-sm py-4">
|
| 234 |
+
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 235 |
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
| 236 |
+
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
| 237 |
+
</svg>
|
| 238 |
+
Computing episode statistics…
|
| 239 |
+
</div>
|
| 240 |
+
)}
|
| 241 |
+
|
| 242 |
+
{/* Episode length section */}
|
| 243 |
+
{els && (
|
| 244 |
+
<>
|
| 245 |
+
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 246 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-4">Episode Lengths</h3>
|
| 247 |
+
<div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
|
| 248 |
+
<Card label="Shortest" value={`${els.shortestEpisodes[0]?.lengthSeconds ?? "–"}s`} />
|
| 249 |
+
<Card label="Longest" value={`${els.longestEpisodes[els.longestEpisodes.length - 1]?.lengthSeconds ?? "–"}s`} />
|
| 250 |
+
<Card label="Mean" value={`${els.meanEpisodeLength}s`} />
|
| 251 |
+
<Card label="Median" value={`${els.medianEpisodeLength}s`} />
|
| 252 |
+
<Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
|
| 253 |
+
</div>
|
| 254 |
+
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
| 255 |
+
<div>
|
| 256 |
+
<p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Shortest</p>
|
| 257 |
+
<table className="w-full text-sm">
|
| 258 |
+
<tbody>
|
| 259 |
+
{els.shortestEpisodes.map((ep) => (
|
| 260 |
+
<tr key={ep.episodeIndex} className="border-b border-slate-800/60">
|
| 261 |
+
<td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
|
| 262 |
+
<td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
|
| 263 |
+
<td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
|
| 264 |
+
</tr>
|
| 265 |
+
))}
|
| 266 |
+
</tbody>
|
| 267 |
+
</table>
|
| 268 |
+
</div>
|
| 269 |
+
<div>
|
| 270 |
+
<p className="text-xs text-slate-400 uppercase tracking-wide mb-2">Top 5 Longest</p>
|
| 271 |
+
<table className="w-full text-sm">
|
| 272 |
+
<tbody>
|
| 273 |
+
{els.longestEpisodes.map((ep) => (
|
| 274 |
+
<tr key={ep.episodeIndex} className="border-b border-slate-800/60">
|
| 275 |
+
<td className="py-1 text-slate-300">ep {ep.episodeIndex}</td>
|
| 276 |
+
<td className="py-1 text-right tabular-nums font-semibold">{ep.lengthSeconds}s</td>
|
| 277 |
+
<td className="py-1 text-right tabular-nums text-slate-500 text-xs">{ep.frames} fr</td>
|
| 278 |
+
</tr>
|
| 279 |
+
))}
|
| 280 |
+
</tbody>
|
| 281 |
+
</table>
|
| 282 |
+
</div>
|
| 283 |
+
</div>
|
| 284 |
+
</div>
|
| 285 |
+
|
| 286 |
+
{els.episodeLengthHistogram.length > 0 && (
|
| 287 |
+
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 288 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
| 289 |
+
Episode Length Distribution
|
| 290 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 291 |
+
{els.episodeLengthHistogram.length} bin{els.episodeLengthHistogram.length !== 1 ? "s" : ""}
|
| 292 |
+
</span>
|
| 293 |
+
</h3>
|
| 294 |
+
<EpisodeLengthHistogram data={els.episodeLengthHistogram} />
|
| 295 |
+
</div>
|
| 296 |
+
)}
|
| 297 |
+
|
| 298 |
+
<EpisodeLengthFilter episodes={els.allEpisodeLengths} />
|
| 299 |
+
</>
|
| 300 |
+
)}
|
| 301 |
+
|
| 302 |
+
{/* Column min/max table */}
|
| 303 |
+
{columnMinMax && columnMinMax.length > 0 && (
|
| 304 |
+
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 305 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
| 306 |
+
Column Min / Max
|
| 307 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">(episode {episodeId})</span>
|
| 308 |
+
</h3>
|
| 309 |
+
<div className="overflow-x-auto max-h-96 overflow-y-auto">
|
| 310 |
+
<table className="w-full text-sm">
|
| 311 |
+
<thead>
|
| 312 |
+
<tr className="text-slate-400 border-b border-slate-700 sticky top-0 bg-slate-800">
|
| 313 |
+
<th className="text-left py-2 pr-4 font-medium">Column</th>
|
| 314 |
+
<th className="text-right py-2 px-4 font-medium">Min</th>
|
| 315 |
+
<th className="text-right py-2 pl-4 font-medium">Max</th>
|
| 316 |
+
</tr>
|
| 317 |
+
</thead>
|
| 318 |
+
<tbody>
|
| 319 |
+
{columnMinMax.map((col) => (
|
| 320 |
+
<tr key={col.column} className="border-b border-slate-800/60 hover:bg-slate-700/20">
|
| 321 |
+
<td className="py-1.5 pr-4 text-slate-300 truncate max-w-xs" title={col.column}>{col.column}</td>
|
| 322 |
+
<td className="py-1.5 px-4 text-right tabular-nums text-slate-300">{col.min}</td>
|
| 323 |
+
<td className="py-1.5 pl-4 text-right tabular-nums text-slate-300">{col.max}</td>
|
| 324 |
+
</tr>
|
| 325 |
+
))}
|
| 326 |
+
</tbody>
|
| 327 |
+
</table>
|
| 328 |
+
</div>
|
| 329 |
+
</div>
|
| 330 |
+
)}
|
| 331 |
+
</div>
|
| 332 |
+
);
|
| 333 |
+
};
|
| 334 |
+
|
| 335 |
+
export default StatsPanel;
|
src/components/urdf-viewer.tsx
ADDED
|
@@ -0,0 +1,366 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import React, { useState, useEffect, useRef, useMemo, useCallback, Suspense } from "react";
|
| 4 |
+
import { Canvas, useFrame, useLoader } from "@react-three/fiber";
|
| 5 |
+
import { OrbitControls, Grid, Environment } from "@react-three/drei";
|
| 6 |
+
import * as THREE from "three";
|
| 7 |
+
import { STLLoader } from "three/addons/loaders/STLLoader.js";
|
| 8 |
+
import {
|
| 9 |
+
SO101_JOINTS,
|
| 10 |
+
SO101_LINKS,
|
| 11 |
+
MATERIAL_COLORS,
|
| 12 |
+
autoMatchJoints,
|
| 13 |
+
type JointDef,
|
| 14 |
+
type MeshDef,
|
| 15 |
+
} from "@/lib/so101-robot";
|
| 16 |
+
import type { EpisodeData } from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 17 |
+
|
| 18 |
+
const SERIES_DELIM = " | ";
|
| 19 |
+
|
| 20 |
+
// ─── STL Mesh component ───
|
| 21 |
+
function STLMesh({ mesh }: { mesh: MeshDef }) {
|
| 22 |
+
const geometry = useLoader(STLLoader, mesh.file);
|
| 23 |
+
const color = MATERIAL_COLORS[mesh.material];
|
| 24 |
+
return (
|
| 25 |
+
<mesh
|
| 26 |
+
geometry={geometry}
|
| 27 |
+
position={mesh.origin.xyz}
|
| 28 |
+
rotation={new THREE.Euler(...mesh.origin.rpy, "XYZ")}
|
| 29 |
+
>
|
| 30 |
+
<meshStandardMaterial
|
| 31 |
+
color={color}
|
| 32 |
+
metalness={mesh.material === "motor" ? 0.7 : 0.1}
|
| 33 |
+
roughness={mesh.material === "motor" ? 0.3 : 0.6}
|
| 34 |
+
/>
|
| 35 |
+
</mesh>
|
| 36 |
+
);
|
| 37 |
+
}
|
| 38 |
+
|
| 39 |
+
// ─── Link visual: renders all meshes for a link ───
|
| 40 |
+
function LinkVisual({ linkIndex }: { linkIndex: number }) {
|
| 41 |
+
const link = SO101_LINKS[linkIndex];
|
| 42 |
+
if (!link) return null;
|
| 43 |
+
return (
|
| 44 |
+
<>
|
| 45 |
+
{link.meshes.map((mesh, i) => (
|
| 46 |
+
<STLMesh key={i} mesh={mesh} />
|
| 47 |
+
))}
|
| 48 |
+
</>
|
| 49 |
+
);
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
// ─── Joint group: applies origin transform + joint rotation ───
|
| 53 |
+
function JointGroup({
|
| 54 |
+
joint,
|
| 55 |
+
angle,
|
| 56 |
+
linkIndex,
|
| 57 |
+
children,
|
| 58 |
+
}: {
|
| 59 |
+
joint: JointDef;
|
| 60 |
+
angle: number;
|
| 61 |
+
linkIndex: number;
|
| 62 |
+
children?: React.ReactNode;
|
| 63 |
+
}) {
|
| 64 |
+
const rotRef = useRef<THREE.Group>(null);
|
| 65 |
+
|
| 66 |
+
useEffect(() => {
|
| 67 |
+
if (rotRef.current) {
|
| 68 |
+
rotRef.current.quaternion.setFromAxisAngle(new THREE.Vector3(...joint.axis), angle);
|
| 69 |
+
}
|
| 70 |
+
}, [angle, joint.axis]);
|
| 71 |
+
|
| 72 |
+
return (
|
| 73 |
+
<group position={joint.origin.xyz} rotation={new THREE.Euler(...joint.origin.rpy, "XYZ")}>
|
| 74 |
+
<group ref={rotRef}>
|
| 75 |
+
<LinkVisual linkIndex={linkIndex} />
|
| 76 |
+
{children}
|
| 77 |
+
</group>
|
| 78 |
+
</group>
|
| 79 |
+
);
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
// ─── Full robot arm ───
|
| 83 |
+
function RobotArm({ angles }: { angles: Record<string, number> }) {
|
| 84 |
+
return (
|
| 85 |
+
<group>
|
| 86 |
+
{/* Base link (no parent joint) */}
|
| 87 |
+
<LinkVisual linkIndex={0} />
|
| 88 |
+
|
| 89 |
+
{/* shoulder_pan → shoulder_link (1) */}
|
| 90 |
+
<JointGroup joint={SO101_JOINTS[0]} angle={angles.shoulder_pan ?? 0} linkIndex={1}>
|
| 91 |
+
{/* shoulder_lift → upper_arm_link (2) */}
|
| 92 |
+
<JointGroup joint={SO101_JOINTS[1]} angle={angles.shoulder_lift ?? 0} linkIndex={2}>
|
| 93 |
+
{/* elbow_flex → lower_arm_link (3) */}
|
| 94 |
+
<JointGroup joint={SO101_JOINTS[2]} angle={angles.elbow_flex ?? 0} linkIndex={3}>
|
| 95 |
+
{/* wrist_flex → wrist_link (4) */}
|
| 96 |
+
<JointGroup joint={SO101_JOINTS[3]} angle={angles.wrist_flex ?? 0} linkIndex={4}>
|
| 97 |
+
{/* wrist_roll → gripper_link (5) */}
|
| 98 |
+
<JointGroup joint={SO101_JOINTS[4]} angle={angles.wrist_roll ?? 0} linkIndex={5}>
|
| 99 |
+
{/* gripper → moving_jaw (6) */}
|
| 100 |
+
<JointGroup joint={SO101_JOINTS[5]} angle={angles.gripper ?? 0} linkIndex={6} />
|
| 101 |
+
</JointGroup>
|
| 102 |
+
</JointGroup>
|
| 103 |
+
</JointGroup>
|
| 104 |
+
</JointGroup>
|
| 105 |
+
</JointGroup>
|
| 106 |
+
</group>
|
| 107 |
+
);
|
| 108 |
+
}
|
| 109 |
+
|
| 110 |
+
// ─── Playback driver (advances frame inside Canvas render loop) ───
|
| 111 |
+
function PlaybackDriver({
|
| 112 |
+
playing,
|
| 113 |
+
fps,
|
| 114 |
+
totalFrames,
|
| 115 |
+
frameRef,
|
| 116 |
+
}: {
|
| 117 |
+
playing: boolean;
|
| 118 |
+
fps: number;
|
| 119 |
+
totalFrames: number;
|
| 120 |
+
frameRef: React.MutableRefObject<number>;
|
| 121 |
+
}) {
|
| 122 |
+
const elapsed = useRef(0);
|
| 123 |
+
useFrame((_, delta) => {
|
| 124 |
+
if (!playing) {
|
| 125 |
+
elapsed.current = 0;
|
| 126 |
+
return;
|
| 127 |
+
}
|
| 128 |
+
elapsed.current += delta;
|
| 129 |
+
const frameDelta = Math.floor(elapsed.current * fps);
|
| 130 |
+
if (frameDelta > 0) {
|
| 131 |
+
elapsed.current -= frameDelta / fps;
|
| 132 |
+
frameRef.current = (frameRef.current + frameDelta) % totalFrames;
|
| 133 |
+
}
|
| 134 |
+
});
|
| 135 |
+
return null;
|
| 136 |
+
}
|
| 137 |
+
|
| 138 |
+
// ─── Detect raw servo values (0-4096) vs radians ───
|
| 139 |
+
function detectAndConvert(values: number[]): number[] {
|
| 140 |
+
if (values.length === 0) return values;
|
| 141 |
+
const max = Math.max(...values.map(Math.abs));
|
| 142 |
+
if (max > 10) return values.map((v) => ((v - 2048) / 2048) * Math.PI);
|
| 143 |
+
return values;
|
| 144 |
+
}
|
| 145 |
+
|
| 146 |
+
// ─── Group columns by feature prefix ───
|
| 147 |
+
function groupColumnsByPrefix(keys: string[]): Record<string, string[]> {
|
| 148 |
+
const groups: Record<string, string[]> = {};
|
| 149 |
+
for (const key of keys) {
|
| 150 |
+
if (key === "timestamp") continue;
|
| 151 |
+
const parts = key.split(SERIES_DELIM);
|
| 152 |
+
const prefix = parts.length > 1 ? parts[0].trim() : "other";
|
| 153 |
+
if (!groups[prefix]) groups[prefix] = [];
|
| 154 |
+
groups[prefix].push(key);
|
| 155 |
+
}
|
| 156 |
+
return groups;
|
| 157 |
+
}
|
| 158 |
+
|
| 159 |
+
// ═══════════════════════════════════════
|
| 160 |
+
// ─── Main URDF Viewer ───
|
| 161 |
+
// ═══════════���═══════════════════════════
|
| 162 |
+
export default function URDFViewer({ data }: { data: EpisodeData }) {
|
| 163 |
+
const { flatChartData, datasetInfo } = data;
|
| 164 |
+
const totalFrames = flatChartData.length;
|
| 165 |
+
const fps = datasetInfo.fps || 30;
|
| 166 |
+
|
| 167 |
+
const columnGroups = useMemo(() => {
|
| 168 |
+
if (totalFrames === 0) return {};
|
| 169 |
+
return groupColumnsByPrefix(Object.keys(flatChartData[0]));
|
| 170 |
+
}, [flatChartData, totalFrames]);
|
| 171 |
+
|
| 172 |
+
const groupNames = useMemo(() => Object.keys(columnGroups), [columnGroups]);
|
| 173 |
+
|
| 174 |
+
const defaultGroup = useMemo(
|
| 175 |
+
() =>
|
| 176 |
+
groupNames.find((g) => g.toLowerCase().includes("state")) ??
|
| 177 |
+
groupNames.find((g) => g.toLowerCase().includes("action")) ??
|
| 178 |
+
groupNames[0] ?? "",
|
| 179 |
+
[groupNames],
|
| 180 |
+
);
|
| 181 |
+
|
| 182 |
+
const [selectedGroup, setSelectedGroup] = useState(defaultGroup);
|
| 183 |
+
useEffect(() => setSelectedGroup(defaultGroup), [defaultGroup]);
|
| 184 |
+
|
| 185 |
+
const selectedColumns = columnGroups[selectedGroup] ?? [];
|
| 186 |
+
const autoMapping = useMemo(() => autoMatchJoints(selectedColumns), [selectedColumns]);
|
| 187 |
+
const [mapping, setMapping] = useState<Record<string, string>>(autoMapping);
|
| 188 |
+
useEffect(() => setMapping(autoMapping), [autoMapping]);
|
| 189 |
+
|
| 190 |
+
const [frame, setFrame] = useState(0);
|
| 191 |
+
const [playing, setPlaying] = useState(false);
|
| 192 |
+
const frameRef = useRef(0);
|
| 193 |
+
|
| 194 |
+
useEffect(() => {
|
| 195 |
+
if (!playing) return;
|
| 196 |
+
const interval = setInterval(() => setFrame(frameRef.current), 33);
|
| 197 |
+
return () => clearInterval(interval);
|
| 198 |
+
}, [playing]);
|
| 199 |
+
|
| 200 |
+
const handleFrameChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
| 201 |
+
const f = parseInt(e.target.value);
|
| 202 |
+
setFrame(f);
|
| 203 |
+
frameRef.current = f;
|
| 204 |
+
}, []);
|
| 205 |
+
|
| 206 |
+
const jointAngles = useMemo(() => {
|
| 207 |
+
if (totalFrames === 0) return {};
|
| 208 |
+
const row = flatChartData[Math.min(frame, totalFrames - 1)];
|
| 209 |
+
const rawValues: number[] = [];
|
| 210 |
+
const jointNames: string[] = [];
|
| 211 |
+
|
| 212 |
+
for (const joint of SO101_JOINTS) {
|
| 213 |
+
const col = mapping[joint.name];
|
| 214 |
+
if (col && typeof row[col] === "number") {
|
| 215 |
+
rawValues.push(row[col]);
|
| 216 |
+
jointNames.push(joint.name);
|
| 217 |
+
}
|
| 218 |
+
}
|
| 219 |
+
|
| 220 |
+
const converted = detectAndConvert(rawValues);
|
| 221 |
+
const angles: Record<string, number> = {};
|
| 222 |
+
jointNames.forEach((name, i) => {
|
| 223 |
+
angles[name] = converted[i];
|
| 224 |
+
});
|
| 225 |
+
return angles;
|
| 226 |
+
}, [flatChartData, frame, mapping, totalFrames]);
|
| 227 |
+
|
| 228 |
+
const currentTime = totalFrames > 0 ? (frame / fps).toFixed(2) : "0.00";
|
| 229 |
+
const totalTime = (totalFrames / fps).toFixed(2);
|
| 230 |
+
|
| 231 |
+
if (totalFrames === 0) {
|
| 232 |
+
return <div className="text-slate-400 p-8 text-center">No trajectory data available for this episode.</div>;
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
return (
|
| 236 |
+
<div className="flex-1 flex flex-col overflow-hidden">
|
| 237 |
+
{/* 3D Viewport */}
|
| 238 |
+
<div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700">
|
| 239 |
+
<Canvas camera={{ position: [0.35, 0.25, 0.3], fov: 45, near: 0.001, far: 10 }}>
|
| 240 |
+
<ambientLight intensity={0.5} />
|
| 241 |
+
<directionalLight position={[3, 5, 4]} intensity={1.2} castShadow />
|
| 242 |
+
<directionalLight position={[-2, 3, -2]} intensity={0.4} />
|
| 243 |
+
<hemisphereLight args={["#b1e1ff", "#444444", 0.4]} />
|
| 244 |
+
<Suspense fallback={null}>
|
| 245 |
+
<RobotArm angles={jointAngles} />
|
| 246 |
+
</Suspense>
|
| 247 |
+
<Grid
|
| 248 |
+
args={[1, 1]}
|
| 249 |
+
cellSize={0.02}
|
| 250 |
+
cellThickness={0.5}
|
| 251 |
+
cellColor="#334155"
|
| 252 |
+
sectionSize={0.1}
|
| 253 |
+
sectionThickness={1}
|
| 254 |
+
sectionColor="#475569"
|
| 255 |
+
fadeDistance={1}
|
| 256 |
+
position={[0, 0, 0]}
|
| 257 |
+
/>
|
| 258 |
+
<OrbitControls target={[0, 0.1, 0]} />
|
| 259 |
+
<PlaybackDriver playing={playing} fps={fps} totalFrames={totalFrames} frameRef={frameRef} />
|
| 260 |
+
</Canvas>
|
| 261 |
+
</div>
|
| 262 |
+
|
| 263 |
+
{/* Controls Panel */}
|
| 264 |
+
<div className="bg-slate-800/90 border-t border-slate-700 p-3 space-y-3 shrink-0">
|
| 265 |
+
{/* Playback bar */}
|
| 266 |
+
<div className="flex items-center gap-3">
|
| 267 |
+
<button
|
| 268 |
+
onClick={() => {
|
| 269 |
+
setPlaying(!playing);
|
| 270 |
+
if (!playing) frameRef.current = frame;
|
| 271 |
+
}}
|
| 272 |
+
className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
|
| 273 |
+
>
|
| 274 |
+
{playing ? (
|
| 275 |
+
<svg width="12" height="14" viewBox="0 0 12 14">
|
| 276 |
+
<rect x="1" y="1" width="3" height="12" fill="white" />
|
| 277 |
+
<rect x="8" y="1" width="3" height="12" fill="white" />
|
| 278 |
+
</svg>
|
| 279 |
+
) : (
|
| 280 |
+
<svg width="12" height="14" viewBox="0 0 12 14">
|
| 281 |
+
<polygon points="2,1 11,7 2,13" fill="white" />
|
| 282 |
+
</svg>
|
| 283 |
+
)}
|
| 284 |
+
</button>
|
| 285 |
+
<input
|
| 286 |
+
type="range"
|
| 287 |
+
min={0}
|
| 288 |
+
max={Math.max(totalFrames - 1, 0)}
|
| 289 |
+
value={frame}
|
| 290 |
+
onChange={handleFrameChange}
|
| 291 |
+
className="flex-1 h-1.5 accent-orange-500 cursor-pointer"
|
| 292 |
+
/>
|
| 293 |
+
<span className="text-xs text-slate-400 tabular-nums w-28 text-right shrink-0">
|
| 294 |
+
{currentTime}s / {totalTime}s
|
| 295 |
+
</span>
|
| 296 |
+
<span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">
|
| 297 |
+
F {frame}/{totalFrames - 1}
|
| 298 |
+
</span>
|
| 299 |
+
</div>
|
| 300 |
+
|
| 301 |
+
{/* Data source + joint mapping */}
|
| 302 |
+
<div className="flex gap-4 items-start">
|
| 303 |
+
<div className="space-y-1 shrink-0">
|
| 304 |
+
<label className="text-xs text-slate-400">Data source</label>
|
| 305 |
+
<div className="flex gap-1 flex-wrap">
|
| 306 |
+
{groupNames.map((name) => (
|
| 307 |
+
<button
|
| 308 |
+
key={name}
|
| 309 |
+
onClick={() => setSelectedGroup(name)}
|
| 310 |
+
className={`px-2 py-1 text-xs rounded transition-colors ${
|
| 311 |
+
selectedGroup === name
|
| 312 |
+
? "bg-orange-600 text-white"
|
| 313 |
+
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
| 314 |
+
}`}
|
| 315 |
+
>
|
| 316 |
+
{name}
|
| 317 |
+
</button>
|
| 318 |
+
))}
|
| 319 |
+
</div>
|
| 320 |
+
</div>
|
| 321 |
+
|
| 322 |
+
<div className="flex-1 overflow-x-auto">
|
| 323 |
+
<table className="w-full text-xs">
|
| 324 |
+
<thead>
|
| 325 |
+
<tr className="text-slate-500">
|
| 326 |
+
<th className="text-left font-normal px-1">URDF Joint</th>
|
| 327 |
+
<th className="text-left font-normal px-1">→</th>
|
| 328 |
+
<th className="text-left font-normal px-1">Dataset Column</th>
|
| 329 |
+
<th className="text-right font-normal px-1">Value (rad)</th>
|
| 330 |
+
</tr>
|
| 331 |
+
</thead>
|
| 332 |
+
<tbody>
|
| 333 |
+
{SO101_JOINTS.map((joint) => (
|
| 334 |
+
<tr key={joint.name} className="border-t border-slate-700/50">
|
| 335 |
+
<td className="px-1 py-0.5 text-slate-300 font-mono">{joint.name}</td>
|
| 336 |
+
<td className="px-1 text-slate-600">→</td>
|
| 337 |
+
<td className="px-1 py-0.5">
|
| 338 |
+
<select
|
| 339 |
+
value={mapping[joint.name] ?? ""}
|
| 340 |
+
onChange={(e) => setMapping((m) => ({ ...m, [joint.name]: e.target.value }))}
|
| 341 |
+
className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]"
|
| 342 |
+
>
|
| 343 |
+
<option value="">-- unmapped --</option>
|
| 344 |
+
{selectedColumns.map((col) => {
|
| 345 |
+
const label = col.split(SERIES_DELIM).pop() ?? col;
|
| 346 |
+
return (
|
| 347 |
+
<option key={col} value={col}>
|
| 348 |
+
{label}
|
| 349 |
+
</option>
|
| 350 |
+
);
|
| 351 |
+
})}
|
| 352 |
+
</select>
|
| 353 |
+
</td>
|
| 354 |
+
<td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
|
| 355 |
+
{jointAngles[joint.name] !== undefined ? jointAngles[joint.name].toFixed(3) : "—"}
|
| 356 |
+
</td>
|
| 357 |
+
</tr>
|
| 358 |
+
))}
|
| 359 |
+
</tbody>
|
| 360 |
+
</table>
|
| 361 |
+
</div>
|
| 362 |
+
</div>
|
| 363 |
+
</div>
|
| 364 |
+
</div>
|
| 365 |
+
);
|
| 366 |
+
}
|
src/lib/so101-robot.ts
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export type JointDef = {
|
| 2 |
+
name: string;
|
| 3 |
+
origin: { xyz: [number, number, number]; rpy: [number, number, number] };
|
| 4 |
+
axis: [number, number, number];
|
| 5 |
+
limits: [number, number];
|
| 6 |
+
};
|
| 7 |
+
|
| 8 |
+
export type MeshDef = {
|
| 9 |
+
file: string;
|
| 10 |
+
origin: { xyz: [number, number, number]; rpy: [number, number, number] };
|
| 11 |
+
material: "3d_printed" | "motor";
|
| 12 |
+
};
|
| 13 |
+
|
| 14 |
+
export type LinkDef = {
|
| 15 |
+
name: string;
|
| 16 |
+
meshes: MeshDef[];
|
| 17 |
+
};
|
| 18 |
+
|
| 19 |
+
const ASSET_BASE = "/urdf/so101/assets";
|
| 20 |
+
const P = Math.PI;
|
| 21 |
+
|
| 22 |
+
// ─── Visual meshes per link (from URDF) ───
|
| 23 |
+
export const SO101_LINKS: LinkDef[] = [
|
| 24 |
+
{
|
| 25 |
+
name: "base_link",
|
| 26 |
+
meshes: [
|
| 27 |
+
{ file: `${ASSET_BASE}/base_motor_holder_so101_v1.stl`, origin: { xyz: [-0.00636471, -9.94414e-05, -0.0024], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
|
| 28 |
+
{ file: `${ASSET_BASE}/base_so101_v2.stl`, origin: { xyz: [-0.00636471, 0, -0.0024], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
|
| 29 |
+
{ file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [0.0263353, 0, 0.0437], rpy: [0, 0, 0] }, material: "motor" },
|
| 30 |
+
{ file: `${ASSET_BASE}/waveshare_mounting_plate_so101_v2.stl`, origin: { xyz: [-0.0309827, -0.000199441, 0.0474], rpy: [P / 2, 0, P / 2] }, material: "3d_printed" },
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
+
{
|
| 34 |
+
name: "shoulder_link",
|
| 35 |
+
meshes: [
|
| 36 |
+
{ file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.0303992, 0.000422241, -0.0417], rpy: [P / 2, P / 2, 0] }, material: "motor" },
|
| 37 |
+
{ file: `${ASSET_BASE}/motor_holder_so101_base_v1.stl`, origin: { xyz: [-0.0675992, -0.000177759, 0.0158499], rpy: [P / 2, -P / 2, 0] }, material: "3d_printed" },
|
| 38 |
+
{ file: `${ASSET_BASE}/rotation_pitch_so101_v1.stl`, origin: { xyz: [0.0122008, 2.22413e-05, 0.0464], rpy: [-P / 2, 0, 0] }, material: "3d_printed" },
|
| 39 |
+
],
|
| 40 |
+
},
|
| 41 |
+
{
|
| 42 |
+
name: "upper_arm_link",
|
| 43 |
+
meshes: [
|
| 44 |
+
{ file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.11257, -0.0155, 0.0187], rpy: [-P, 0, -P / 2] }, material: "motor" },
|
| 45 |
+
{ file: `${ASSET_BASE}/upper_arm_so101_v1.stl`, origin: { xyz: [-0.065085, 0.012, 0.0182], rpy: [P, 0, 0] }, material: "3d_printed" },
|
| 46 |
+
],
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
name: "lower_arm_link",
|
| 50 |
+
meshes: [
|
| 51 |
+
{ file: `${ASSET_BASE}/under_arm_so101_v1.stl`, origin: { xyz: [-0.0648499, -0.032, 0.0182], rpy: [P, 0, 0] }, material: "3d_printed" },
|
| 52 |
+
{ file: `${ASSET_BASE}/motor_holder_so101_wrist_v1.stl`, origin: { xyz: [-0.0648499, -0.032, 0.018], rpy: [-P, 0, 0] }, material: "3d_printed" },
|
| 53 |
+
{ file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [-0.1224, 0.0052, 0.0187], rpy: [-P, 0, -P] }, material: "motor" },
|
| 54 |
+
],
|
| 55 |
+
},
|
| 56 |
+
{
|
| 57 |
+
name: "wrist_link",
|
| 58 |
+
meshes: [
|
| 59 |
+
{ file: `${ASSET_BASE}/sts3215_03a_no_horn_v1.stl`, origin: { xyz: [0, -0.0424, 0.0306], rpy: [P / 2, P / 2, 0] }, material: "motor" },
|
| 60 |
+
{ file: `${ASSET_BASE}/wrist_roll_pitch_so101_v2.stl`, origin: { xyz: [0, -0.028, 0.0181], rpy: [-P / 2, -P / 2, 0] }, material: "3d_printed" },
|
| 61 |
+
],
|
| 62 |
+
},
|
| 63 |
+
{
|
| 64 |
+
name: "gripper_link",
|
| 65 |
+
meshes: [
|
| 66 |
+
{ file: `${ASSET_BASE}/sts3215_03a_v1.stl`, origin: { xyz: [0.0077, 0.0001, -0.0234], rpy: [-P / 2, 0, 0] }, material: "motor" },
|
| 67 |
+
{ file: `${ASSET_BASE}/wrist_roll_follower_so101_v1.stl`, origin: { xyz: [0, -0.000218214, 0.000949706], rpy: [-P, 0, 0] }, material: "3d_printed" },
|
| 68 |
+
],
|
| 69 |
+
},
|
| 70 |
+
{
|
| 71 |
+
name: "moving_jaw_link",
|
| 72 |
+
meshes: [
|
| 73 |
+
{ file: `${ASSET_BASE}/moving_jaw_so101_v1.stl`, origin: { xyz: [0, 0, 0.0189], rpy: [0, 0, 0] }, material: "3d_printed" },
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
];
|
| 77 |
+
|
| 78 |
+
// Kinematic chain: each joint connects a parent link to a child link
|
| 79 |
+
// Index in SO101_LINKS: base=0, shoulder=1, upper_arm=2, lower_arm=3, wrist=4, gripper=5, jaw=6
|
| 80 |
+
export const SO101_JOINTS: JointDef[] = [
|
| 81 |
+
{
|
| 82 |
+
name: "shoulder_pan",
|
| 83 |
+
origin: { xyz: [0.0388353, -8.97657e-09, 0.0624], rpy: [P, 4.18253e-17, -P] },
|
| 84 |
+
axis: [0, 0, 1],
|
| 85 |
+
limits: [-1.91986, 1.91986],
|
| 86 |
+
},
|
| 87 |
+
{
|
| 88 |
+
name: "shoulder_lift",
|
| 89 |
+
origin: { xyz: [-0.0303992, -0.0182778, -0.0542], rpy: [-P / 2, -P / 2, 0] },
|
| 90 |
+
axis: [0, 0, 1],
|
| 91 |
+
limits: [-1.74533, 1.74533],
|
| 92 |
+
},
|
| 93 |
+
{
|
| 94 |
+
name: "elbow_flex",
|
| 95 |
+
origin: { xyz: [-0.11257, -0.028, 1.73763e-16], rpy: [0, 0, P / 2] },
|
| 96 |
+
axis: [0, 0, 1],
|
| 97 |
+
limits: [-1.69, 1.69],
|
| 98 |
+
},
|
| 99 |
+
{
|
| 100 |
+
name: "wrist_flex",
|
| 101 |
+
origin: { xyz: [-0.1349, 0.0052, 0], rpy: [0, 0, -P / 2] },
|
| 102 |
+
axis: [0, 0, 1],
|
| 103 |
+
limits: [-1.65806, 1.65806],
|
| 104 |
+
},
|
| 105 |
+
{
|
| 106 |
+
name: "wrist_roll",
|
| 107 |
+
origin: { xyz: [0, -0.0611, 0.0181], rpy: [P / 2, 0.0486795, P] },
|
| 108 |
+
axis: [0, 0, 1],
|
| 109 |
+
limits: [-2.74385, 2.84121],
|
| 110 |
+
},
|
| 111 |
+
{
|
| 112 |
+
name: "gripper",
|
| 113 |
+
origin: { xyz: [0.0202, 0.0188, -0.0234], rpy: [P / 2, 0, 0] },
|
| 114 |
+
axis: [0, 0, 1],
|
| 115 |
+
limits: [-0.174533, 1.74533],
|
| 116 |
+
},
|
| 117 |
+
];
|
| 118 |
+
|
| 119 |
+
export const MATERIAL_COLORS = {
|
| 120 |
+
"3d_printed": "#FFD700",
|
| 121 |
+
motor: "#1a1a1a",
|
| 122 |
+
} as const;
|
| 123 |
+
|
| 124 |
+
export function isSO101Robot(robotType: string | null): boolean {
|
| 125 |
+
if (!robotType) return false;
|
| 126 |
+
const lower = robotType.toLowerCase();
|
| 127 |
+
return lower.includes("so100") || lower.includes("so101") || lower === "so_follower";
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Collect all unique STL file paths for preloading
|
| 131 |
+
export function getAllSTLPaths(): string[] {
|
| 132 |
+
const paths = new Set<string>();
|
| 133 |
+
for (const link of SO101_LINKS) {
|
| 134 |
+
for (const mesh of link.meshes) {
|
| 135 |
+
paths.add(mesh.file);
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
return [...paths];
|
| 139 |
+
}
|
| 140 |
+
|
| 141 |
+
// Auto-match dataset columns to URDF joint names
|
| 142 |
+
export function autoMatchJoints(columnKeys: string[]): Record<string, string> {
|
| 143 |
+
const mapping: Record<string, string> = {};
|
| 144 |
+
for (const joint of SO101_JOINTS) {
|
| 145 |
+
const exactMatch = columnKeys.find((k) => {
|
| 146 |
+
const suffix = k.split(" | ").pop()?.trim() ?? k;
|
| 147 |
+
return suffix === joint.name;
|
| 148 |
+
});
|
| 149 |
+
if (exactMatch) { mapping[joint.name] = exactMatch; continue; }
|
| 150 |
+
const fuzzy = columnKeys.find((k) => k.toLowerCase().includes(joint.name));
|
| 151 |
+
if (fuzzy) mapping[joint.name] = fuzzy;
|
| 152 |
+
}
|
| 153 |
+
return mapping;
|
| 154 |
+
}
|