Spaces:
Running
Running
style: run prettier formatting
Browse filesCo-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- src/app/[org]/[dataset]/[episode]/actions.ts +17 -4
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +101 -40
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +579 -283
- src/app/page.tsx +14 -2
- src/components/action-insights-panel.tsx +813 -248
- src/components/data-recharts.tsx +119 -30
- src/components/filtering-panel.tsx +223 -62
- src/components/overview-panel.tsx +118 -30
- src/components/side-nav.tsx +4 -1
- src/components/simple-videos-player.tsx +18 -15
- src/components/stats-panel.tsx +96 -21
- src/components/urdf-viewer.tsx +352 -129
- src/components/videos-player.tsx +7 -6
- src/context/flagged-episodes-context.tsx +44 -13
- src/lib/so101-robot.ts +5 -1
- src/utils/versionUtils.ts +17 -9
src/app/[org]/[dataset]/[episode]/actions.ts
CHANGED
|
@@ -28,7 +28,11 @@ export async function fetchEpisodeFrames(
|
|
| 28 |
): Promise<EpisodeFramesData> {
|
| 29 |
const repoId = `${org}/${dataset}`;
|
| 30 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 31 |
-
return loadAllEpisodeFrameInfo(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
export async function fetchCrossEpisodeVariance(
|
|
@@ -37,7 +41,12 @@ export async function fetchCrossEpisodeVariance(
|
|
| 37 |
): Promise<CrossEpisodeVarianceData | null> {
|
| 38 |
const repoId = `${org}/${dataset}`;
|
| 39 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 40 |
-
return loadCrossEpisodeActionVariance(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
}
|
| 42 |
|
| 43 |
export async function fetchEpisodeChartData(
|
|
@@ -47,6 +56,10 @@ export async function fetchEpisodeChartData(
|
|
| 47 |
): Promise<Record<string, number>[]> {
|
| 48 |
const repoId = `${org}/${dataset}`;
|
| 49 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 50 |
-
return loadEpisodeFlatChartData(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 51 |
}
|
| 52 |
-
|
|
|
|
| 28 |
): Promise<EpisodeFramesData> {
|
| 29 |
const repoId = `${org}/${dataset}`;
|
| 30 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 31 |
+
return loadAllEpisodeFrameInfo(
|
| 32 |
+
repoId,
|
| 33 |
+
version,
|
| 34 |
+
info as unknown as DatasetMetadata,
|
| 35 |
+
);
|
| 36 |
}
|
| 37 |
|
| 38 |
export async function fetchCrossEpisodeVariance(
|
|
|
|
| 41 |
): Promise<CrossEpisodeVarianceData | null> {
|
| 42 |
const repoId = `${org}/${dataset}`;
|
| 43 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 44 |
+
return loadCrossEpisodeActionVariance(
|
| 45 |
+
repoId,
|
| 46 |
+
version,
|
| 47 |
+
info as unknown as DatasetMetadata,
|
| 48 |
+
info.fps,
|
| 49 |
+
);
|
| 50 |
}
|
| 51 |
|
| 52 |
export async function fetchEpisodeChartData(
|
|
|
|
| 56 |
): Promise<Record<string, number>[]> {
|
| 57 |
const repoId = `${org}/${dataset}`;
|
| 58 |
const { version, info } = await getDatasetVersionAndInfo(repoId);
|
| 59 |
+
return loadEpisodeFlatChartData(
|
| 60 |
+
repoId,
|
| 61 |
+
version,
|
| 62 |
+
info as unknown as DatasetMetadata,
|
| 63 |
+
episodeId,
|
| 64 |
+
);
|
| 65 |
}
|
|
|
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
CHANGED
|
@@ -22,13 +22,25 @@ import {
|
|
| 22 |
type EpisodeFramesData,
|
| 23 |
type CrossEpisodeVarianceData,
|
| 24 |
} from "./fetch-data";
|
| 25 |
-
import {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
|
| 28 |
-
const ActionInsightsPanel = lazy(
|
|
|
|
|
|
|
| 29 |
const FilteringPanel = lazy(() => import("@/components/filtering-panel"));
|
| 30 |
|
| 31 |
-
type ActiveTab =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
|
| 33 |
export default function EpisodeViewer({
|
| 34 |
data,
|
|
@@ -65,7 +77,15 @@ export default function EpisodeViewer({
|
|
| 65 |
);
|
| 66 |
}
|
| 67 |
|
| 68 |
-
function EpisodeViewerInner({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
const {
|
| 70 |
datasetInfo,
|
| 71 |
episodeId,
|
|
@@ -82,7 +102,9 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 82 |
const loadStartRef = useRef(performance.now());
|
| 83 |
useEffect(() => {
|
| 84 |
if (!isLoading) {
|
| 85 |
-
console.log(
|
|
|
|
|
|
|
| 86 |
}
|
| 87 |
}, [isLoading]);
|
| 88 |
|
|
@@ -93,35 +115,56 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 93 |
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
| 94 |
if (typeof window !== "undefined") {
|
| 95 |
const stored = sessionStorage.getItem("activeTab");
|
| 96 |
-
if (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
return stored as ActiveTab;
|
| 98 |
}
|
| 99 |
}
|
| 100 |
return "episodes";
|
| 101 |
});
|
| 102 |
const [, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
|
| 103 |
-
const [episodeLengthStats, setEpisodeLengthStats] =
|
|
|
|
| 104 |
const [statsLoading, setStatsLoading] = useState(false);
|
| 105 |
const statsLoadedRef = useRef(false);
|
| 106 |
-
const [episodeFramesData, setEpisodeFramesData] =
|
|
|
|
| 107 |
const [framesLoading, setFramesLoading] = useState(false);
|
| 108 |
const framesLoadedRef = useRef(false);
|
| 109 |
const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() => {
|
| 110 |
-
if (typeof window !== "undefined")
|
|
|
|
| 111 |
return false;
|
| 112 |
});
|
| 113 |
const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() => {
|
| 114 |
-
if (typeof window !== "undefined")
|
|
|
|
| 115 |
return false;
|
| 116 |
});
|
| 117 |
-
const [crossEpData, setCrossEpData] =
|
|
|
|
| 118 |
const [insightsLoading, setInsightsLoading] = useState(false);
|
| 119 |
const insightsLoadedRef = useRef(false);
|
| 120 |
|
| 121 |
// Persist UI state across episode navigations
|
| 122 |
-
useEffect(() => {
|
| 123 |
-
|
| 124 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 125 |
|
| 126 |
const loadStats = () => {
|
| 127 |
if (statsLoadedRef.current) return;
|
|
@@ -163,7 +206,10 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 163 |
if (activeTab === "statistics") loadStats();
|
| 164 |
if (activeTab === "frames") loadFrames();
|
| 165 |
if (activeTab === "insights") loadInsights();
|
| 166 |
-
if (activeTab === "filtering") {
|
|
|
|
|
|
|
|
|
|
| 167 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 168 |
}, []);
|
| 169 |
|
|
@@ -172,7 +218,10 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 172 |
if (tab === "statistics") loadStats();
|
| 173 |
if (tab === "frames") loadFrames();
|
| 174 |
if (tab === "insights") loadInsights();
|
| 175 |
-
if (tab === "filtering") {
|
|
|
|
|
|
|
|
|
|
| 176 |
};
|
| 177 |
|
| 178 |
// Use context for time sync
|
|
@@ -186,7 +235,7 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 186 |
(currentPage - 1) * pageSize,
|
| 187 |
currentPage * pageSize,
|
| 188 |
);
|
| 189 |
-
|
| 190 |
// Preload adjacent episodes' videos via <link rel="preload"> tags
|
| 191 |
useEffect(() => {
|
| 192 |
if (!org || !dataset) return;
|
|
@@ -370,21 +419,22 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 370 |
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 371 |
)}
|
| 372 |
</button>
|
| 373 |
-
{hasURDFSupport(datasetInfo.robot_type) &&
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
| 382 |
-
|
| 383 |
-
|
| 384 |
-
|
| 385 |
-
|
| 386 |
-
|
| 387 |
-
|
|
|
|
| 388 |
</div>
|
| 389 |
|
| 390 |
{/* Body: sidebar + content */}
|
|
@@ -430,7 +480,9 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 430 |
href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
|
| 431 |
target="_blank"
|
| 432 |
>
|
| 433 |
-
<p className="text-lg font-semibold">
|
|
|
|
|
|
|
| 434 |
</a>
|
| 435 |
|
| 436 |
<p className="font-mono text-lg font-semibold">
|
|
@@ -451,14 +503,18 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 451 |
{task && (
|
| 452 |
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
|
| 453 |
<p className="text-slate-300">
|
| 454 |
-
<span className="font-semibold text-slate-100">
|
|
|
|
|
|
|
| 455 |
</p>
|
| 456 |
<div className="mt-2 text-slate-300">
|
| 457 |
-
{task
|
| 458 |
-
|
| 459 |
-
|
| 460 |
-
|
| 461 |
-
|
|
|
|
|
|
|
| 462 |
</div>
|
| 463 |
</div>
|
| 464 |
)}
|
|
@@ -484,7 +540,12 @@ function EpisodeViewerInner({ data, org, dataset }: { data: EpisodeData; org?: s
|
|
| 484 |
)}
|
| 485 |
|
| 486 |
{activeTab === "frames" && (
|
| 487 |
-
<OverviewPanel
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 488 |
)}
|
| 489 |
|
| 490 |
{activeTab === "insights" && (
|
|
|
|
| 22 |
type EpisodeFramesData,
|
| 23 |
type CrossEpisodeVarianceData,
|
| 24 |
} from "./fetch-data";
|
| 25 |
+
import {
|
| 26 |
+
fetchEpisodeLengthStats,
|
| 27 |
+
fetchEpisodeFrames,
|
| 28 |
+
fetchCrossEpisodeVariance,
|
| 29 |
+
} from "./actions";
|
| 30 |
|
| 31 |
const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
|
| 32 |
+
const ActionInsightsPanel = lazy(
|
| 33 |
+
() => import("@/components/action-insights-panel"),
|
| 34 |
+
);
|
| 35 |
const FilteringPanel = lazy(() => import("@/components/filtering-panel"));
|
| 36 |
|
| 37 |
+
type ActiveTab =
|
| 38 |
+
| "episodes"
|
| 39 |
+
| "statistics"
|
| 40 |
+
| "frames"
|
| 41 |
+
| "insights"
|
| 42 |
+
| "filtering"
|
| 43 |
+
| "urdf";
|
| 44 |
|
| 45 |
export default function EpisodeViewer({
|
| 46 |
data,
|
|
|
|
| 77 |
);
|
| 78 |
}
|
| 79 |
|
| 80 |
+
function EpisodeViewerInner({
|
| 81 |
+
data,
|
| 82 |
+
org,
|
| 83 |
+
dataset,
|
| 84 |
+
}: {
|
| 85 |
+
data: EpisodeData;
|
| 86 |
+
org?: string;
|
| 87 |
+
dataset?: string;
|
| 88 |
+
}) {
|
| 89 |
const {
|
| 90 |
datasetInfo,
|
| 91 |
episodeId,
|
|
|
|
| 102 |
const loadStartRef = useRef(performance.now());
|
| 103 |
useEffect(() => {
|
| 104 |
if (!isLoading) {
|
| 105 |
+
console.log(
|
| 106 |
+
`[perf] Loading complete in ${(performance.now() - loadStartRef.current).toFixed(0)}ms (videos: ${videosReady ? "✓" : "…"}, charts: ${chartsReady ? "✓" : "…"})`,
|
| 107 |
+
);
|
| 108 |
}
|
| 109 |
}, [isLoading]);
|
| 110 |
|
|
|
|
| 115 |
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
| 116 |
if (typeof window !== "undefined") {
|
| 117 |
const stored = sessionStorage.getItem("activeTab");
|
| 118 |
+
if (
|
| 119 |
+
stored &&
|
| 120 |
+
[
|
| 121 |
+
"episodes",
|
| 122 |
+
"statistics",
|
| 123 |
+
"frames",
|
| 124 |
+
"insights",
|
| 125 |
+
"filtering",
|
| 126 |
+
"urdf",
|
| 127 |
+
].includes(stored)
|
| 128 |
+
) {
|
| 129 |
return stored as ActiveTab;
|
| 130 |
}
|
| 131 |
}
|
| 132 |
return "episodes";
|
| 133 |
});
|
| 134 |
const [, setColumnMinMax] = useState<ColumnMinMax[] | null>(null);
|
| 135 |
+
const [episodeLengthStats, setEpisodeLengthStats] =
|
| 136 |
+
useState<EpisodeLengthStats | null>(null);
|
| 137 |
const [statsLoading, setStatsLoading] = useState(false);
|
| 138 |
const statsLoadedRef = useRef(false);
|
| 139 |
+
const [episodeFramesData, setEpisodeFramesData] =
|
| 140 |
+
useState<EpisodeFramesData | null>(null);
|
| 141 |
const [framesLoading, setFramesLoading] = useState(false);
|
| 142 |
const framesLoadedRef = useRef(false);
|
| 143 |
const [framesFlaggedOnly, setFramesFlaggedOnly] = useState(() => {
|
| 144 |
+
if (typeof window !== "undefined")
|
| 145 |
+
return sessionStorage.getItem("framesFlaggedOnly") === "true";
|
| 146 |
return false;
|
| 147 |
});
|
| 148 |
const [sidebarFlaggedOnly, setSidebarFlaggedOnly] = useState(() => {
|
| 149 |
+
if (typeof window !== "undefined")
|
| 150 |
+
return sessionStorage.getItem("sidebarFlaggedOnly") === "true";
|
| 151 |
return false;
|
| 152 |
});
|
| 153 |
+
const [crossEpData, setCrossEpData] =
|
| 154 |
+
useState<CrossEpisodeVarianceData | null>(null);
|
| 155 |
const [insightsLoading, setInsightsLoading] = useState(false);
|
| 156 |
const insightsLoadedRef = useRef(false);
|
| 157 |
|
| 158 |
// Persist UI state across episode navigations
|
| 159 |
+
useEffect(() => {
|
| 160 |
+
sessionStorage.setItem("activeTab", activeTab);
|
| 161 |
+
}, [activeTab]);
|
| 162 |
+
useEffect(() => {
|
| 163 |
+
sessionStorage.setItem("sidebarFlaggedOnly", String(sidebarFlaggedOnly));
|
| 164 |
+
}, [sidebarFlaggedOnly]);
|
| 165 |
+
useEffect(() => {
|
| 166 |
+
sessionStorage.setItem("framesFlaggedOnly", String(framesFlaggedOnly));
|
| 167 |
+
}, [framesFlaggedOnly]);
|
| 168 |
|
| 169 |
const loadStats = () => {
|
| 170 |
if (statsLoadedRef.current) return;
|
|
|
|
| 206 |
if (activeTab === "statistics") loadStats();
|
| 207 |
if (activeTab === "frames") loadFrames();
|
| 208 |
if (activeTab === "insights") loadInsights();
|
| 209 |
+
if (activeTab === "filtering") {
|
| 210 |
+
loadStats();
|
| 211 |
+
loadInsights();
|
| 212 |
+
}
|
| 213 |
// eslint-disable-next-line react-hooks/exhaustive-deps
|
| 214 |
}, []);
|
| 215 |
|
|
|
|
| 218 |
if (tab === "statistics") loadStats();
|
| 219 |
if (tab === "frames") loadFrames();
|
| 220 |
if (tab === "insights") loadInsights();
|
| 221 |
+
if (tab === "filtering") {
|
| 222 |
+
loadStats();
|
| 223 |
+
loadInsights();
|
| 224 |
+
}
|
| 225 |
};
|
| 226 |
|
| 227 |
// Use context for time sync
|
|
|
|
| 235 |
(currentPage - 1) * pageSize,
|
| 236 |
currentPage * pageSize,
|
| 237 |
);
|
| 238 |
+
|
| 239 |
// Preload adjacent episodes' videos via <link rel="preload"> tags
|
| 240 |
useEffect(() => {
|
| 241 |
if (!org || !dataset) return;
|
|
|
|
| 419 |
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 420 |
)}
|
| 421 |
</button>
|
| 422 |
+
{hasURDFSupport(datasetInfo.robot_type) &&
|
| 423 |
+
datasetInfo.codebase_version >= "v3.0" && (
|
| 424 |
+
<button
|
| 425 |
+
className={`px-6 py-2.5 text-sm font-medium transition-colors relative ${
|
| 426 |
+
activeTab === "urdf"
|
| 427 |
+
? "text-orange-400"
|
| 428 |
+
: "text-slate-400 hover:text-slate-200"
|
| 429 |
+
}`}
|
| 430 |
+
onClick={() => handleTabChange("urdf")}
|
| 431 |
+
>
|
| 432 |
+
3D Replay
|
| 433 |
+
{activeTab === "urdf" && (
|
| 434 |
+
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-orange-500" />
|
| 435 |
+
)}
|
| 436 |
+
</button>
|
| 437 |
+
)}
|
| 438 |
</div>
|
| 439 |
|
| 440 |
{/* Body: sidebar + content */}
|
|
|
|
| 480 |
href={`https://huggingface.co/datasets/${datasetInfo.repoId}`}
|
| 481 |
target="_blank"
|
| 482 |
>
|
| 483 |
+
<p className="text-lg font-semibold">
|
| 484 |
+
{datasetInfo.repoId}
|
| 485 |
+
</p>
|
| 486 |
</a>
|
| 487 |
|
| 488 |
<p className="font-mono text-lg font-semibold">
|
|
|
|
| 503 |
{task && (
|
| 504 |
<div className="mb-6 p-4 bg-slate-800 rounded-lg border border-slate-600">
|
| 505 |
<p className="text-slate-300">
|
| 506 |
+
<span className="font-semibold text-slate-100">
|
| 507 |
+
Language Instruction:
|
| 508 |
+
</span>
|
| 509 |
</p>
|
| 510 |
<div className="mt-2 text-slate-300">
|
| 511 |
+
{task
|
| 512 |
+
.split("\n")
|
| 513 |
+
.map((instruction: string, index: number) => (
|
| 514 |
+
<p key={index} className="mb-1">
|
| 515 |
+
{instruction}
|
| 516 |
+
</p>
|
| 517 |
+
))}
|
| 518 |
</div>
|
| 519 |
</div>
|
| 520 |
)}
|
|
|
|
| 540 |
)}
|
| 541 |
|
| 542 |
{activeTab === "frames" && (
|
| 543 |
+
<OverviewPanel
|
| 544 |
+
data={episodeFramesData}
|
| 545 |
+
loading={framesLoading}
|
| 546 |
+
flaggedOnly={framesFlaggedOnly}
|
| 547 |
+
onFlaggedOnlyChange={setFramesFlaggedOnly}
|
| 548 |
+
/>
|
| 549 |
)}
|
| 550 |
|
| 551 |
{activeTab === "insights" && (
|
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -5,7 +5,10 @@ import {
|
|
| 5 |
readParquetAsObjects,
|
| 6 |
} from "@/utils/parquetUtils";
|
| 7 |
import { pick } from "@/utils/pick";
|
| 8 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 9 |
import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
|
| 10 |
import {
|
| 11 |
processChartDataGroups,
|
|
@@ -121,9 +124,10 @@ export async function getEpisodeData(
|
|
| 121 |
}
|
| 122 |
|
| 123 |
console.time(`[perf] getEpisodeData (${version})`);
|
| 124 |
-
const result =
|
| 125 |
-
|
| 126 |
-
|
|
|
|
| 127 |
console.timeEnd(`[perf] getEpisodeData (${version})`);
|
| 128 |
|
| 129 |
// Extract camera resolutions from features
|
|
@@ -136,7 +140,12 @@ export async function getEpisodeData(
|
|
| 136 |
robot_type: rawInfo.robot_type ?? null,
|
| 137 |
codebase_version: rawInfo.codebase_version,
|
| 138 |
total_tasks: rawInfo.total_tasks ?? 0,
|
| 139 |
-
dataset_size_mb:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 140 |
cameras,
|
| 141 |
};
|
| 142 |
|
|
@@ -157,19 +166,19 @@ export async function getAdjacentEpisodesVideoInfo(
|
|
| 157 |
try {
|
| 158 |
const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
|
| 159 |
const info = rawInfo as unknown as DatasetMetadata;
|
| 160 |
-
|
| 161 |
const totalEpisodes = info.total_episodes;
|
| 162 |
const adjacentVideos: AdjacentEpisodeVideos[] = [];
|
| 163 |
-
|
| 164 |
// Calculate adjacent episode IDs
|
| 165 |
for (let offset = -radius; offset <= radius; offset++) {
|
| 166 |
if (offset === 0) continue; // Skip current episode
|
| 167 |
-
|
| 168 |
const episodeId = currentEpisodeId + offset;
|
| 169 |
if (episodeId >= 0 && episodeId < totalEpisodes) {
|
| 170 |
try {
|
| 171 |
let videosInfo: VideoInfo[] = [];
|
| 172 |
-
|
| 173 |
if (version === "v3.0") {
|
| 174 |
const episodeMetadata = await loadEpisodeMetadataV3Simple(
|
| 175 |
repoId,
|
|
@@ -185,34 +194,34 @@ export async function getAdjacentEpisodesVideoInfo(
|
|
| 185 |
} else {
|
| 186 |
// For v2.x, use simpler video info extraction
|
| 187 |
if (info.video_path) {
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
const videoPath = formatStringWithVars(info.video_path!, {
|
| 193 |
-
|
| 194 |
episode_chunk: episode_chunk
|
| 195 |
.toString()
|
| 196 |
.padStart(PADDING.CHUNK_INDEX, "0"),
|
| 197 |
episode_index: episodeId
|
| 198 |
.toString()
|
| 199 |
.padStart(PADDING.EPISODE_INDEX, "0"),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
});
|
| 201 |
-
return {
|
| 202 |
-
filename: key,
|
| 203 |
-
url: buildVersionedUrl(repoId, version, videoPath),
|
| 204 |
-
};
|
| 205 |
-
});
|
| 206 |
}
|
| 207 |
}
|
| 208 |
-
|
| 209 |
adjacentVideos.push({ episodeId, videosInfo });
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
}
|
| 214 |
}
|
| 215 |
-
|
| 216 |
return adjacentVideos;
|
| 217 |
} catch {
|
| 218 |
// Return empty array on error
|
|
@@ -253,25 +262,25 @@ async function getEpisodeDataV2(
|
|
| 253 |
.map((x) => parseInt(x.trim(), 10))
|
| 254 |
.filter((x) => !isNaN(x));
|
| 255 |
|
| 256 |
-
|
| 257 |
const videosInfo =
|
| 258 |
info.video_path !== null
|
| 259 |
? Object.entries(info.features)
|
| 260 |
-
|
| 261 |
-
|
| 262 |
const videoPath = formatStringWithVars(info.video_path!, {
|
| 263 |
-
|
| 264 |
episode_chunk: episode_chunk
|
| 265 |
.toString()
|
| 266 |
.padStart(PADDING.CHUNK_INDEX, "0"),
|
| 267 |
episode_index: episodeId
|
| 268 |
.toString()
|
| 269 |
.padStart(PADDING.EPISODE_INDEX, "0"),
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
| 274 |
-
|
| 275 |
})
|
| 276 |
: [];
|
| 277 |
|
|
@@ -297,7 +306,9 @@ async function getEpisodeDataV2(
|
|
| 297 |
return {
|
| 298 |
key,
|
| 299 |
value: Array.isArray(column_names)
|
| 300 |
-
? column_names.map(
|
|
|
|
|
|
|
| 301 |
: Array.from(
|
| 302 |
{ length: columnNames.find((c) => c.key === key)?.length ?? 1 },
|
| 303 |
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
|
@@ -318,49 +329,56 @@ async function getEpisodeDataV2(
|
|
| 318 |
|
| 319 |
const arrayBuffer = await fetchParquetFile(parquetUrl);
|
| 320 |
const allData = await readParquetAsObjects(arrayBuffer, []);
|
| 321 |
-
|
| 322 |
// Extract task from language_instruction fields, task field, or tasks.jsonl
|
| 323 |
let task: string | undefined;
|
| 324 |
-
|
| 325 |
if (allData.length > 0) {
|
| 326 |
const firstRow = allData[0];
|
| 327 |
const languageInstructions: string[] = [];
|
| 328 |
-
|
| 329 |
-
if (typeof firstRow.language_instruction ===
|
| 330 |
languageInstructions.push(firstRow.language_instruction);
|
| 331 |
}
|
| 332 |
-
|
| 333 |
let instructionNum = 2;
|
| 334 |
-
while (
|
| 335 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 336 |
instructionNum++;
|
| 337 |
}
|
| 338 |
-
|
| 339 |
if (languageInstructions.length > 0) {
|
| 340 |
-
task = languageInstructions.join(
|
| 341 |
}
|
| 342 |
}
|
| 343 |
-
|
| 344 |
-
if (!task && allData.length > 0 && typeof allData[0].task ===
|
| 345 |
task = allData[0].task;
|
| 346 |
}
|
| 347 |
-
|
| 348 |
if (!task && allData.length > 0) {
|
| 349 |
try {
|
| 350 |
const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
|
| 351 |
const tasksResponse = await fetch(tasksUrl);
|
| 352 |
-
|
| 353 |
if (tasksResponse.ok) {
|
| 354 |
const tasksText = await tasksResponse.text();
|
| 355 |
const tasksData = tasksText
|
| 356 |
.split("\n")
|
| 357 |
.filter((line) => line.trim())
|
| 358 |
.map((line) => JSON.parse(line));
|
| 359 |
-
|
| 360 |
if (tasksData && tasksData.length > 0) {
|
| 361 |
const taskIndex = allData[0].task_index;
|
| 362 |
-
const taskIndexNum =
|
| 363 |
-
|
|
|
|
|
|
|
|
|
|
| 364 |
if (taskData) {
|
| 365 |
task = taskData.task;
|
| 366 |
}
|
|
@@ -370,7 +388,7 @@ async function getEpisodeDataV2(
|
|
| 370 |
// No tasks metadata file for this v2.x dataset
|
| 371 |
}
|
| 372 |
}
|
| 373 |
-
|
| 374 |
// Build chart data from already-parsed allData (no second parquet parse)
|
| 375 |
const seriesNames = [
|
| 376 |
"timestamp",
|
|
@@ -385,7 +403,7 @@ async function getEpisodeDataV2(
|
|
| 385 |
if (Array.isArray(rawVal)) {
|
| 386 |
rawVal.forEach((v: unknown, i: number) => {
|
| 387 |
if (i < col.value.length) obj[col.value[i]] = Number(v);
|
| 388 |
-
|
| 389 |
} else if (rawVal !== undefined) {
|
| 390 |
obj[col.value[0]] = Number(rawVal);
|
| 391 |
}
|
|
@@ -458,7 +476,7 @@ async function getEpisodeDataV3(
|
|
| 458 |
version,
|
| 459 |
episodeId,
|
| 460 |
);
|
| 461 |
-
|
| 462 |
// Create video info with segmentation using the metadata
|
| 463 |
const videosInfo = extractVideoInfoV3WithSegmentation(
|
| 464 |
repoId,
|
|
@@ -468,10 +486,12 @@ async function getEpisodeDataV3(
|
|
| 468 |
);
|
| 469 |
|
| 470 |
// Load episode data for charts
|
| 471 |
-
const { chartDataGroups, flatChartData, ignoredColumns, task } =
|
|
|
|
| 472 |
|
| 473 |
-
const duration = episodeMetadata.length
|
| 474 |
-
|
|
|
|
| 475 |
|
| 476 |
return {
|
| 477 |
datasetInfo,
|
|
@@ -492,96 +512,124 @@ async function loadEpisodeDataV3(
|
|
| 492 |
version: string,
|
| 493 |
info: DatasetMetadata,
|
| 494 |
episodeMetadata: EpisodeMetadataV3,
|
| 495 |
-
): Promise<{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 496 |
// Build data file path using chunk and file indices
|
| 497 |
const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
|
| 498 |
const dataFileIndex = bigIntToNumber(episodeMetadata.data_file_index, 0);
|
| 499 |
const dataPath = buildV3DataPath(dataChunkIndex, dataFileIndex);
|
| 500 |
-
|
| 501 |
try {
|
| 502 |
const dataUrl = buildVersionedUrl(repoId, version, dataPath);
|
| 503 |
const arrayBuffer = await fetchParquetFile(dataUrl);
|
| 504 |
const fullData = await readParquetAsObjects(arrayBuffer, []);
|
| 505 |
-
|
| 506 |
// Extract the episode-specific data slice
|
| 507 |
const fromIndex = bigIntToNumber(episodeMetadata.dataset_from_index, 0);
|
| 508 |
-
const toIndex = bigIntToNumber(
|
| 509 |
-
|
|
|
|
|
|
|
|
|
|
| 510 |
// Find the starting index of this parquet file by checking the first row's index
|
| 511 |
// This handles the case where episodes are split across multiple parquet files
|
| 512 |
let fileStartIndex = 0;
|
| 513 |
if (fullData.length > 0 && fullData[0].index !== undefined) {
|
| 514 |
fileStartIndex = Number(fullData[0].index);
|
| 515 |
}
|
| 516 |
-
|
| 517 |
// Adjust indices to be relative to this file's starting position
|
| 518 |
const localFromIndex = Math.max(0, fromIndex - fileStartIndex);
|
| 519 |
const localToIndex = Math.min(fullData.length, toIndex - fileStartIndex);
|
| 520 |
-
|
| 521 |
const episodeData = fullData.slice(localFromIndex, localToIndex);
|
| 522 |
-
|
| 523 |
if (episodeData.length === 0) {
|
| 524 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
}
|
| 526 |
-
|
| 527 |
// Convert to the same format as v2.x for compatibility with existing chart code
|
| 528 |
-
const { chartDataGroups, flatChartData, ignoredColumns } =
|
| 529 |
-
|
|
|
|
| 530 |
// First check for language_instruction fields in the data (preferred)
|
| 531 |
let task: string | undefined;
|
| 532 |
if (episodeData.length > 0) {
|
| 533 |
const languageInstructions: string[] = [];
|
| 534 |
-
|
| 535 |
const extractInstructions = (row: Record<string, unknown>) => {
|
| 536 |
-
if (typeof row.language_instruction ===
|
| 537 |
languageInstructions.push(row.language_instruction);
|
| 538 |
}
|
| 539 |
let num = 2;
|
| 540 |
-
while (typeof row[`language_instruction_${num}`] ===
|
| 541 |
-
languageInstructions.push(
|
|
|
|
|
|
|
| 542 |
num++;
|
| 543 |
}
|
| 544 |
};
|
| 545 |
|
| 546 |
extractInstructions(episodeData[0]);
|
| 547 |
-
|
| 548 |
// If no instructions in first row, check middle and last rows
|
| 549 |
if (languageInstructions.length === 0 && episodeData.length > 1) {
|
| 550 |
-
for (const idx of [
|
|
|
|
|
|
|
|
|
|
| 551 |
extractInstructions(episodeData[idx]);
|
| 552 |
if (languageInstructions.length > 0) break;
|
| 553 |
}
|
| 554 |
}
|
| 555 |
-
|
| 556 |
if (languageInstructions.length > 0) {
|
| 557 |
-
task = languageInstructions.join(
|
| 558 |
}
|
| 559 |
}
|
| 560 |
-
|
| 561 |
// Fall back to tasks metadata parquet
|
| 562 |
if (!task && episodeData.length > 0) {
|
| 563 |
try {
|
| 564 |
-
const tasksUrl = buildVersionedUrl(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
|
| 566 |
const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
|
| 567 |
-
|
| 568 |
if (tasksData.length > 0) {
|
| 569 |
const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1);
|
| 570 |
|
| 571 |
if (taskIndexNum >= 0 && taskIndexNum < tasksData.length) {
|
| 572 |
const taskData = tasksData[taskIndexNum];
|
| 573 |
const rawTask = taskData.__index_level_0__ ?? taskData.task;
|
| 574 |
-
task = typeof rawTask ===
|
| 575 |
}
|
| 576 |
}
|
| 577 |
} catch {
|
| 578 |
// Could not load tasks metadata
|
| 579 |
}
|
| 580 |
}
|
| 581 |
-
|
| 582 |
return { chartDataGroups, flatChartData, ignoredColumns, task };
|
| 583 |
} catch {
|
| 584 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
}
|
| 586 |
}
|
| 587 |
|
|
@@ -590,17 +638,20 @@ function processEpisodeDataForCharts(
|
|
| 590 |
episodeData: Record<string, unknown>[],
|
| 591 |
info: DatasetMetadata,
|
| 592 |
episodeMetadata?: EpisodeMetadataV3,
|
| 593 |
-
): {
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
| 595 |
// Convert parquet data to chart format
|
| 596 |
let seriesNames: string[] = [];
|
| 597 |
-
|
| 598 |
// Dynamically create a mapping from numeric indices to feature names based on actual dataset features
|
| 599 |
const v3IndexToFeatureMap: Record<string, string> = {};
|
| 600 |
-
|
| 601 |
// Build mapping based on what features actually exist in the dataset
|
| 602 |
const featureKeys = Object.keys(info.features);
|
| 603 |
-
|
| 604 |
// Common feature order for v3.0 datasets (but only include if they exist)
|
| 605 |
const expectedFeatureOrder = [
|
| 606 |
"observation.state",
|
|
@@ -613,7 +664,7 @@ function processEpisodeDataForCharts(
|
|
| 613 |
"index",
|
| 614 |
"task_index",
|
| 615 |
];
|
| 616 |
-
|
| 617 |
// Map indices to features that actually exist
|
| 618 |
let currentIndex = 0;
|
| 619 |
expectedFeatureOrder.forEach((feature) => {
|
|
@@ -622,16 +673,17 @@ function processEpisodeDataForCharts(
|
|
| 622 |
currentIndex++;
|
| 623 |
}
|
| 624 |
});
|
| 625 |
-
|
| 626 |
// Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
|
| 627 |
const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
|
| 628 |
|
| 629 |
// Create columns structure similar to V2.1 for proper hierarchical naming
|
| 630 |
const columns: ColumnDef[] = Object.entries(info.features)
|
| 631 |
-
.filter(
|
| 632 |
-
[
|
| 633 |
-
|
| 634 |
-
|
|
|
|
| 635 |
)
|
| 636 |
.map(([key, feature]) => {
|
| 637 |
let column_names: unknown = feature.names;
|
|
@@ -642,7 +694,9 @@ function processEpisodeDataForCharts(
|
|
| 642 |
return {
|
| 643 |
key,
|
| 644 |
value: Array.isArray(column_names)
|
| 645 |
-
? column_names.map(
|
|
|
|
|
|
|
| 646 |
: Array.from(
|
| 647 |
{ length: feature.shape[0] || 1 },
|
| 648 |
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
|
@@ -654,19 +708,19 @@ function processEpisodeDataForCharts(
|
|
| 654 |
if (episodeData.length > 0) {
|
| 655 |
const firstRow = episodeData[0];
|
| 656 |
const allKeys: string[] = [];
|
| 657 |
-
|
| 658 |
Object.entries(firstRow || {}).forEach(([key, value]) => {
|
| 659 |
if (key === "timestamp") return; // Skip timestamp, we'll add it separately
|
| 660 |
-
|
| 661 |
// Map numeric key to feature name if available
|
| 662 |
const featureName = v3IndexToFeatureMap[key] || key;
|
| 663 |
-
|
| 664 |
// Skip if feature doesn't exist in dataset
|
| 665 |
if (!info.features[featureName]) return;
|
| 666 |
-
|
| 667 |
// Skip excluded columns
|
| 668 |
if (excludedColumns.includes(featureName)) return;
|
| 669 |
-
|
| 670 |
// Find the matching column definition to get proper names
|
| 671 |
const columnDef = columns.find((col) => col.key === featureName);
|
| 672 |
if (columnDef && Array.isArray(value) && value.length > 0) {
|
|
@@ -684,7 +738,7 @@ function processEpisodeDataForCharts(
|
|
| 684 |
allKeys.push(featureName);
|
| 685 |
}
|
| 686 |
});
|
| 687 |
-
|
| 688 |
seriesNames = ["timestamp", ...allKeys];
|
| 689 |
} else {
|
| 690 |
// Fallback to column-based approach like V2.1
|
|
@@ -693,7 +747,7 @@ function processEpisodeDataForCharts(
|
|
| 693 |
|
| 694 |
const chartData = episodeData.map((row, index) => {
|
| 695 |
const obj: Record<string, number> = {};
|
| 696 |
-
|
| 697 |
// Add timestamp aligned with video timing
|
| 698 |
// For v3.0, we need to map the episode data index to the actual video duration
|
| 699 |
let videoDuration = episodeData.length; // Fallback to data length
|
|
@@ -705,7 +759,7 @@ function processEpisodeDataForCharts(
|
|
| 705 |
}
|
| 706 |
obj["timestamp"] =
|
| 707 |
(index / Math.max(episodeData.length - 1, 1)) * videoDuration;
|
| 708 |
-
|
| 709 |
// Add all data columns using hierarchical naming
|
| 710 |
if (row && typeof row === "object") {
|
| 711 |
Object.entries(row).forEach(([key, value]) => {
|
|
@@ -713,19 +767,19 @@ function processEpisodeDataForCharts(
|
|
| 713 |
// Timestamp is already handled above
|
| 714 |
return;
|
| 715 |
}
|
| 716 |
-
|
| 717 |
// Map numeric key to feature name if available
|
| 718 |
const featureName = v3IndexToFeatureMap[key] || key;
|
| 719 |
-
|
| 720 |
// Skip if feature doesn't exist in dataset
|
| 721 |
if (!info.features[featureName]) return;
|
| 722 |
-
|
| 723 |
// Skip excluded columns
|
| 724 |
if (excludedColumns.includes(featureName)) return;
|
| 725 |
-
|
| 726 |
// Find the matching column definition to get proper series names
|
| 727 |
const columnDef = columns.find((col) => col.key === featureName);
|
| 728 |
-
|
| 729 |
if (Array.isArray(value) && columnDef) {
|
| 730 |
// For array values like observation.state and action, use proper hierarchical naming
|
| 731 |
value.forEach((val, idx) => {
|
|
@@ -744,7 +798,7 @@ function processEpisodeDataForCharts(
|
|
| 744 |
}
|
| 745 |
});
|
| 746 |
}
|
| 747 |
-
|
| 748 |
return obj;
|
| 749 |
});
|
| 750 |
|
|
@@ -794,23 +848,29 @@ function extractVideoInfoV3WithSegmentation(
|
|
| 794 |
const cameraSpecificKeys = Object.keys(episodeMetadata).filter((key) =>
|
| 795 |
key.startsWith(`videos/${videoKey}/`),
|
| 796 |
);
|
| 797 |
-
|
| 798 |
-
let chunkIndex: number,
|
| 799 |
-
|
| 800 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 801 |
|
| 802 |
if (cameraSpecificKeys.length > 0) {
|
| 803 |
chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]);
|
| 804 |
fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]);
|
| 805 |
-
segmentStart =
|
| 806 |
-
|
|
|
|
|
|
|
| 807 |
} else {
|
| 808 |
chunkIndex = episodeMetadata.video_chunk_index || 0;
|
| 809 |
fileIndex = episodeMetadata.video_file_index || 0;
|
| 810 |
segmentStart = episodeMetadata.video_from_timestamp || 0;
|
| 811 |
segmentEnd = episodeMetadata.video_to_timestamp || 30;
|
| 812 |
}
|
| 813 |
-
|
| 814 |
// Convert BigInt to number for timestamps
|
| 815 |
const startNum = bigIntToNumber(segmentStart);
|
| 816 |
const endNum = bigIntToNumber(segmentEnd);
|
|
@@ -821,7 +881,7 @@ function extractVideoInfoV3WithSegmentation(
|
|
| 821 |
bigIntToNumber(fileIndex, 0),
|
| 822 |
);
|
| 823 |
const fullUrl = buildVersionedUrl(repoId, version, videoPath);
|
| 824 |
-
|
| 825 |
return {
|
| 826 |
filename: videoKey,
|
| 827 |
url: fullUrl,
|
|
@@ -844,11 +904,11 @@ async function loadEpisodeMetadataV3Simple(
|
|
| 844 |
): Promise<EpisodeMetadataV3> {
|
| 845 |
// Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
|
| 846 |
// Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
|
| 847 |
-
|
| 848 |
let episodeRow = null;
|
| 849 |
let fileIndex = 0;
|
| 850 |
const chunkIndex = 0; // Episodes are typically in chunk-000
|
| 851 |
-
|
| 852 |
// Try loading episode metadata files until we find the episode
|
| 853 |
while (!episodeRow) {
|
| 854 |
const episodesMetadataPath = buildV3EpisodesMetadataPath(
|
|
@@ -864,23 +924,23 @@ async function loadEpisodeMetadataV3Simple(
|
|
| 864 |
try {
|
| 865 |
const arrayBuffer = await fetchParquetFile(episodesMetadataUrl);
|
| 866 |
const episodesData = await readParquetAsObjects(arrayBuffer, []);
|
| 867 |
-
|
| 868 |
if (episodesData.length === 0) {
|
| 869 |
// Empty file, try next one
|
| 870 |
fileIndex++;
|
| 871 |
continue;
|
| 872 |
}
|
| 873 |
-
|
| 874 |
// Find the row for the requested episode by episode_index
|
| 875 |
for (const row of episodesData) {
|
| 876 |
const parsedRow = parseEpisodeRowSimple(row);
|
| 877 |
-
|
| 878 |
if (parsedRow.episode_index === episodeId) {
|
| 879 |
episodeRow = row;
|
| 880 |
break;
|
| 881 |
}
|
| 882 |
}
|
| 883 |
-
|
| 884 |
if (!episodeRow) {
|
| 885 |
// Not in this file, try the next one
|
| 886 |
fileIndex++;
|
|
@@ -892,13 +952,15 @@ async function loadEpisodeMetadataV3Simple(
|
|
| 892 |
);
|
| 893 |
}
|
| 894 |
}
|
| 895 |
-
|
| 896 |
// Convert the row to a usable format
|
| 897 |
return parseEpisodeRowSimple(episodeRow);
|
| 898 |
}
|
| 899 |
|
| 900 |
// Simple parser for episode row - focuses on key fields for episodes
|
| 901 |
-
function parseEpisodeRowSimple(
|
|
|
|
|
|
|
| 902 |
// v3.0 uses named keys in the episode metadata
|
| 903 |
if (row && typeof row === "object") {
|
| 904 |
// Check if this is v3.0 format with named keys
|
|
@@ -906,24 +968,29 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
|
|
| 906 |
// v3.0 format - use named keys
|
| 907 |
// Convert BigInt values to numbers
|
| 908 |
const toBigIntSafe = (value: unknown): number => {
|
| 909 |
-
if (typeof value ===
|
| 910 |
-
if (typeof value ===
|
| 911 |
-
if (typeof value ===
|
| 912 |
return 0;
|
| 913 |
};
|
| 914 |
-
|
| 915 |
const toNumSafe = (value: unknown): number => {
|
| 916 |
-
if (typeof value ===
|
| 917 |
-
if (typeof value ===
|
| 918 |
-
if (typeof value ===
|
| 919 |
return 0;
|
| 920 |
};
|
| 921 |
|
| 922 |
// Handle video metadata - look for video-specific keys
|
| 923 |
-
const videoKeys = Object.keys(row).filter(
|
| 924 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 925 |
if (videoKeys.length > 0) {
|
| 926 |
-
const videoBaseName = videoKeys[0].replace(
|
| 927 |
videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
|
| 928 |
videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]);
|
| 929 |
videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]);
|
|
@@ -931,46 +998,55 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
|
|
| 931 |
}
|
| 932 |
|
| 933 |
const episodeData: EpisodeMetadataV3 = {
|
| 934 |
-
episode_index: toBigIntSafe(row[
|
| 935 |
-
data_chunk_index: toBigIntSafe(row[
|
| 936 |
-
data_file_index: toBigIntSafe(row[
|
| 937 |
-
dataset_from_index: toBigIntSafe(row[
|
| 938 |
-
dataset_to_index: toBigIntSafe(row[
|
| 939 |
-
length: toBigIntSafe(row[
|
| 940 |
video_chunk_index: videoChunkIndex,
|
| 941 |
video_file_index: videoFileIndex,
|
| 942 |
video_from_timestamp: videoFromTs,
|
| 943 |
video_to_timestamp: videoToTs,
|
| 944 |
};
|
| 945 |
-
|
| 946 |
// Store per-camera metadata for extractVideoInfoV3WithSegmentation
|
| 947 |
-
Object.keys(row).forEach(key => {
|
| 948 |
-
if (key.startsWith(
|
| 949 |
const val = row[key];
|
| 950 |
-
episodeData[key] =
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 951 |
}
|
| 952 |
});
|
| 953 |
-
|
| 954 |
return episodeData as EpisodeMetadataV3;
|
| 955 |
} else {
|
| 956 |
// Fallback to numeric keys for compatibility
|
| 957 |
const toNum = (v: unknown, fallback = 0): number =>
|
| 958 |
-
typeof v ===
|
|
|
|
|
|
|
|
|
|
|
|
|
| 959 |
return {
|
| 960 |
-
episode_index: toNum(row[
|
| 961 |
-
data_chunk_index: toNum(row[
|
| 962 |
-
data_file_index: toNum(row[
|
| 963 |
-
dataset_from_index: toNum(row[
|
| 964 |
-
dataset_to_index: toNum(row[
|
| 965 |
-
video_chunk_index: toNum(row[
|
| 966 |
-
video_file_index: toNum(row[
|
| 967 |
-
video_from_timestamp: toNum(row[
|
| 968 |
-
video_to_timestamp: toNum(row[
|
| 969 |
-
length: toNum(row[
|
| 970 |
};
|
| 971 |
}
|
| 972 |
}
|
| 973 |
-
|
| 974 |
// Fallback if parsing fails
|
| 975 |
const fallback = {
|
| 976 |
episode_index: 0,
|
|
@@ -984,18 +1060,18 @@ function parseEpisodeRowSimple(row: Record<string, unknown>): EpisodeMetadataV3
|
|
| 984 |
video_to_timestamp: 30,
|
| 985 |
length: 30,
|
| 986 |
};
|
| 987 |
-
|
| 988 |
return fallback;
|
| 989 |
}
|
| 990 |
|
| 991 |
-
|
| 992 |
-
|
| 993 |
// ─── Stats computation ───────────────────────────────────────────
|
| 994 |
|
| 995 |
/**
|
| 996 |
* Compute per-column min/max values from the current episode's chart data.
|
| 997 |
*/
|
| 998 |
-
export function computeColumnMinMax(
|
|
|
|
|
|
|
| 999 |
const stats: Record<string, { min: number; max: number }> = {};
|
| 1000 |
|
| 1001 |
for (const group of chartDataGroups) {
|
|
@@ -1057,7 +1133,10 @@ export async function loadAllEpisodeLengthsV3(
|
|
| 1057 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1058 |
for (const row of rows) {
|
| 1059 |
const parsed = parseEpisodeRowSimple(row);
|
| 1060 |
-
allEpisodes.push({
|
|
|
|
|
|
|
|
|
|
| 1061 |
}
|
| 1062 |
fileIndex++;
|
| 1063 |
} catch {
|
|
@@ -1073,7 +1152,9 @@ export async function loadAllEpisodeLengthsV3(
|
|
| 1073 |
lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
|
| 1074 |
}));
|
| 1075 |
|
| 1076 |
-
const sortedByLength = [...withSeconds].sort(
|
|
|
|
|
|
|
| 1077 |
const shortestEpisodes = sortedByLength.slice(0, 5);
|
| 1078 |
const longestEpisodes = sortedByLength.slice(-5).reverse();
|
| 1079 |
|
|
@@ -1083,11 +1164,13 @@ export async function loadAllEpisodeLengthsV3(
|
|
| 1083 |
|
| 1084 |
const sorted = [...lengths].sort((a, b) => a - b);
|
| 1085 |
const mid = Math.floor(sorted.length / 2);
|
| 1086 |
-
const median =
|
| 1087 |
-
|
| 1088 |
-
|
|
|
|
| 1089 |
|
| 1090 |
-
const variance =
|
|
|
|
| 1091 |
const std = Math.round(Math.sqrt(variance) * 100) / 100;
|
| 1092 |
|
| 1093 |
// Build histogram
|
|
@@ -1096,25 +1179,39 @@ export async function loadAllEpisodeLengthsV3(
|
|
| 1096 |
|
| 1097 |
if (histMax === histMin) {
|
| 1098 |
return {
|
| 1099 |
-
shortestEpisodes,
|
| 1100 |
-
|
| 1101 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1102 |
};
|
| 1103 |
}
|
| 1104 |
|
| 1105 |
const p1 = sorted[Math.floor(sorted.length * 0.01)];
|
| 1106 |
const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
|
| 1107 |
-
const range =
|
| 1108 |
|
| 1109 |
-
const targetBins = Math.max(
|
|
|
|
|
|
|
|
|
|
| 1110 |
const rawBinWidth = range / targetBins;
|
| 1111 |
const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
|
| 1112 |
const niceSteps = [1, 2, 2.5, 5, 10];
|
| 1113 |
-
const niceBinWidth =
|
|
|
|
|
|
|
| 1114 |
|
| 1115 |
const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
|
| 1116 |
const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
|
| 1117 |
-
const actualBinCount = Math.max(
|
|
|
|
|
|
|
|
|
|
| 1118 |
const bins = Array.from({ length: actualBinCount }, () => 0);
|
| 1119 |
|
| 1120 |
for (const len of lengths) {
|
|
@@ -1131,8 +1228,12 @@ export async function loadAllEpisodeLengthsV3(
|
|
| 1131 |
});
|
| 1132 |
|
| 1133 |
return {
|
| 1134 |
-
shortestEpisodes,
|
| 1135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1136 |
episodeLengthHistogram: histogram,
|
| 1137 |
};
|
| 1138 |
} catch {
|
|
@@ -1149,7 +1250,9 @@ export async function loadAllEpisodeFrameInfo(
|
|
| 1149 |
version: string,
|
| 1150 |
info: DatasetMetadata,
|
| 1151 |
): Promise<EpisodeFramesData> {
|
| 1152 |
-
const videoFeatures = Object.entries(info.features).filter(
|
|
|
|
|
|
|
| 1153 |
if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
|
| 1154 |
|
| 1155 |
const cameras = videoFeatures.map(([key]) => key);
|
|
@@ -1161,16 +1264,30 @@ export async function loadAllEpisodeFrameInfo(
|
|
| 1161 |
while (true) {
|
| 1162 |
const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1163 |
try {
|
| 1164 |
-
const buf = await fetchParquetFile(
|
|
|
|
|
|
|
| 1165 |
const rows = await readParquetAsObjects(buf, []);
|
| 1166 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1167 |
for (const row of rows) {
|
| 1168 |
const epIdx = Number(row["episode_index"] ?? 0);
|
| 1169 |
for (const cam of cameras) {
|
| 1170 |
-
const cIdx = Number(
|
| 1171 |
-
|
| 1172 |
-
|
| 1173 |
-
const
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1174 |
const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
|
| 1175 |
framesByCamera[cam].push({
|
| 1176 |
episodeIndex: epIdx,
|
|
@@ -1210,7 +1327,10 @@ export async function loadAllEpisodeFrameInfo(
|
|
| 1210 |
|
| 1211 |
// ─── Cross-episode action variance ──────────────────────────────
|
| 1212 |
|
| 1213 |
-
export type LowMovementEpisode = {
|
|
|
|
|
|
|
|
|
|
| 1214 |
|
| 1215 |
export type AggVelocityStat = {
|
| 1216 |
name: string;
|
|
@@ -1271,10 +1391,16 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1271 |
maxEpisodes = 500,
|
| 1272 |
numTimeBins = 50,
|
| 1273 |
): Promise<CrossEpisodeVarianceData | null> {
|
| 1274 |
-
const actionEntry = Object.entries(info.features)
|
| 1275 |
-
|
|
|
|
| 1276 |
if (!actionEntry) {
|
| 1277 |
-
console.warn(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1278 |
return null;
|
| 1279 |
}
|
| 1280 |
|
|
@@ -1286,17 +1412,27 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1286 |
names = Object.values(names)[0];
|
| 1287 |
}
|
| 1288 |
const actionNames = Array.isArray(names)
|
| 1289 |
-
? (names as string[]).map(n => `${actionKey}${SERIES_NAME_DELIMITER}${n}`)
|
| 1290 |
-
: Array.from(
|
|
|
|
|
|
|
|
|
|
| 1291 |
|
| 1292 |
// State feature for alignment computation
|
| 1293 |
-
const stateEntry = Object.entries(info.features)
|
| 1294 |
-
|
|
|
|
| 1295 |
const stateKey = stateEntry?.[0] ?? null;
|
| 1296 |
const stateDim = stateEntry?.[1].shape[0] ?? 0;
|
| 1297 |
|
| 1298 |
// Collect episode metadata
|
| 1299 |
-
type EpMeta = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1300 |
const allEps: EpMeta[] = [];
|
| 1301 |
|
| 1302 |
if (version === "v3.0") {
|
|
@@ -1304,7 +1440,9 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1304 |
while (true) {
|
| 1305 |
const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1306 |
try {
|
| 1307 |
-
const buf = await fetchParquetFile(
|
|
|
|
|
|
|
| 1308 |
const rows = await readParquetAsObjects(buf, []);
|
| 1309 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1310 |
for (const row of rows) {
|
|
@@ -1318,7 +1456,9 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1318 |
});
|
| 1319 |
}
|
| 1320 |
fileIndex++;
|
| 1321 |
-
} catch {
|
|
|
|
|
|
|
| 1322 |
}
|
| 1323 |
} else {
|
| 1324 |
for (let i = 0; i < info.total_episodes; i++) {
|
|
@@ -1327,17 +1467,24 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1327 |
}
|
| 1328 |
|
| 1329 |
if (allEps.length < 2) {
|
| 1330 |
-
console.warn(
|
|
|
|
|
|
|
| 1331 |
return null;
|
| 1332 |
}
|
| 1333 |
-
console.log(
|
|
|
|
|
|
|
| 1334 |
|
| 1335 |
// Sample episodes evenly
|
| 1336 |
-
const sampled =
|
| 1337 |
-
|
| 1338 |
-
|
| 1339 |
-
|
| 1340 |
-
|
|
|
|
|
|
|
|
|
|
| 1341 |
|
| 1342 |
// Load action (and state) data per episode
|
| 1343 |
const episodeActions: { index: number; actions: number[][] }[] = [];
|
|
@@ -1355,9 +1502,14 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1355 |
const ep0 = eps[0];
|
| 1356 |
const dataPath = `data/chunk-${ep0.chunkIdx.toString().padStart(3, "0")}/file-${ep0.fileIdx.toString().padStart(3, "0")}.parquet`;
|
| 1357 |
try {
|
| 1358 |
-
const buf = await fetchParquetFile(
|
|
|
|
|
|
|
| 1359 |
const rows = await readParquetAsObjects(buf, []);
|
| 1360 |
-
const fileStart =
|
|
|
|
|
|
|
|
|
|
| 1361 |
|
| 1362 |
for (const ep of eps) {
|
| 1363 |
const localFrom = Math.max(0, ep.from - fileStart);
|
|
@@ -1374,10 +1526,14 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1374 |
}
|
| 1375 |
if (actions.length > 0) {
|
| 1376 |
episodeActions.push({ index: ep.index, actions });
|
| 1377 |
-
episodeStates.push(
|
|
|
|
|
|
|
| 1378 |
}
|
| 1379 |
}
|
| 1380 |
-
} catch {
|
|
|
|
|
|
|
| 1381 |
}
|
| 1382 |
} else {
|
| 1383 |
const chunkSize = info.chunks_size || 1000;
|
|
@@ -1388,7 +1544,9 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1388 |
episode_index: ep.index.toString().padStart(6, "0"),
|
| 1389 |
});
|
| 1390 |
try {
|
| 1391 |
-
const buf = await fetchParquetFile(
|
|
|
|
|
|
|
| 1392 |
const rows = await readParquetAsObjects(buf, []);
|
| 1393 |
const actions: number[][] = [];
|
| 1394 |
const states: number[][] = [];
|
|
@@ -1411,22 +1569,39 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1411 |
}
|
| 1412 |
if (actions.length > 0) {
|
| 1413 |
episodeActions.push({ index: ep.index, actions });
|
| 1414 |
-
episodeStates.push(
|
|
|
|
|
|
|
| 1415 |
}
|
| 1416 |
-
} catch {
|
|
|
|
|
|
|
| 1417 |
}
|
| 1418 |
}
|
| 1419 |
|
| 1420 |
if (episodeActions.length < 2) {
|
| 1421 |
-
console.warn(
|
|
|
|
|
|
|
| 1422 |
return null;
|
| 1423 |
}
|
| 1424 |
-
console.log(
|
|
|
|
|
|
|
| 1425 |
|
| 1426 |
// Resample each episode to numTimeBins and compute variance
|
| 1427 |
-
const timeBins = Array.from(
|
| 1428 |
-
|
| 1429 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1430 |
const counts = new Uint32Array(numTimeBins);
|
| 1431 |
|
| 1432 |
for (const { actions: epActions } of episodeActions) {
|
|
@@ -1448,7 +1623,10 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1448 |
const row: number[] = [];
|
| 1449 |
const n = counts[b];
|
| 1450 |
for (let d = 0; d < actionDim; d++) {
|
| 1451 |
-
if (n < 2) {
|
|
|
|
|
|
|
|
|
|
| 1452 |
const mean = sums[b][d] / n;
|
| 1453 |
row.push(sumsSq[b][d] / n - mean * mean);
|
| 1454 |
}
|
|
@@ -1456,26 +1634,34 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1456 |
}
|
| 1457 |
|
| 1458 |
// Per-episode average movement per frame: mean L2 norm of frame-to-frame action deltas
|
| 1459 |
-
const movementScores: LowMovementEpisode[] = episodeActions.map(
|
| 1460 |
-
|
| 1461 |
-
|
| 1462 |
-
|
| 1463 |
-
let
|
| 1464 |
-
|
| 1465 |
-
|
| 1466 |
-
|
|
|
|
|
|
|
|
|
|
| 1467 |
}
|
| 1468 |
-
|
| 1469 |
-
|
| 1470 |
-
|
| 1471 |
-
|
| 1472 |
-
|
|
|
|
|
|
|
| 1473 |
|
| 1474 |
movementScores.sort((a, b) => a.totalMovement - b.totalMovement);
|
| 1475 |
const lowMovementEpisodes = movementScores.slice(0, 10);
|
| 1476 |
|
| 1477 |
// Aggregated velocity stats: pool deltas from all episodes
|
| 1478 |
-
const shortName = (k: string) => {
|
|
|
|
|
|
|
|
|
|
| 1479 |
|
| 1480 |
const aggVelocity: AggVelocityStat[] = (() => {
|
| 1481 |
const binCount = 30;
|
|
@@ -1486,79 +1672,127 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1486 |
deltas.push((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
|
| 1487 |
}
|
| 1488 |
}
|
| 1489 |
-
if (deltas.length === 0)
|
| 1490 |
-
|
| 1491 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1492 |
const mean = sum / deltas.length;
|
| 1493 |
-
let varSum = 0;
|
|
|
|
| 1494 |
const std = Math.sqrt(varSum / deltas.length);
|
| 1495 |
const range = hi - lo || 1;
|
| 1496 |
const binW = range / binCount;
|
| 1497 |
const bins = new Array(binCount).fill(0);
|
| 1498 |
-
for (const v of deltas) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1499 |
return { name: shortName(actionNames[d]), std, maxAbs, bins, lo, hi };
|
| 1500 |
});
|
| 1501 |
})();
|
| 1502 |
|
| 1503 |
// Aggregated autocorrelation: average per-episode ACFs
|
| 1504 |
const aggAutocorrelation: AggAutocorrelation | null = (() => {
|
| 1505 |
-
const maxLag = Math.min(
|
| 1506 |
-
|
| 1507 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1508 |
if (maxLag < 2) return null;
|
| 1509 |
|
| 1510 |
-
const avgAcf: number[][] = Array.from({ length: actionDim }, () =>
|
|
|
|
|
|
|
| 1511 |
let epCount = 0;
|
| 1512 |
|
| 1513 |
for (const { actions: ep } of episodeActions) {
|
| 1514 |
if (ep.length < maxLag * 2) continue;
|
| 1515 |
epCount++;
|
| 1516 |
for (let d = 0; d < actionDim; d++) {
|
| 1517 |
-
const vals = ep.map(row => row[d] ?? 0);
|
| 1518 |
const n = vals.length;
|
| 1519 |
const m = vals.reduce((a, b) => a + b, 0) / n;
|
| 1520 |
-
const centered = vals.map(v => v - m);
|
| 1521 |
const vari = centered.reduce((a, v) => a + v * v, 0);
|
| 1522 |
if (vari === 0) continue;
|
| 1523 |
for (let lag = 1; lag <= maxLag; lag++) {
|
| 1524 |
let s = 0;
|
| 1525 |
-
for (let t = 0; t < n - lag; t++)
|
|
|
|
| 1526 |
avgAcf[d][lag - 1] += s / vari;
|
| 1527 |
}
|
| 1528 |
}
|
| 1529 |
}
|
| 1530 |
|
| 1531 |
if (epCount === 0) return null;
|
| 1532 |
-
for (let d = 0; d < actionDim; d++)
|
|
|
|
| 1533 |
|
| 1534 |
const shortKeys = actionNames.map(shortName);
|
| 1535 |
const chartData = Array.from({ length: maxLag }, (_, lag) => {
|
| 1536 |
-
const row: Record<string, number> = {
|
| 1537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1538 |
return row;
|
| 1539 |
});
|
| 1540 |
|
| 1541 |
// Suggested chunk: median lag where ACF drops below 0.5
|
| 1542 |
-
const lags = avgAcf
|
| 1543 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1544 |
|
| 1545 |
return { chartData, suggestedChunk, shortKeys };
|
| 1546 |
})();
|
| 1547 |
|
| 1548 |
// Per-episode jerkiness: mean |Δa| across all dimensions
|
| 1549 |
-
const jerkyEpisodes: JerkyEpisode[] = episodeActions
|
| 1550 |
-
|
| 1551 |
-
|
| 1552 |
-
|
| 1553 |
-
|
| 1554 |
-
|
|
|
|
|
|
|
|
|
|
| 1555 |
}
|
| 1556 |
-
|
| 1557 |
-
|
| 1558 |
-
|
| 1559 |
|
| 1560 |
// Speed distribution: all episode movement scores (not just lowest 10)
|
| 1561 |
-
const speedDistribution: SpeedDistEntry[] = movementScores.map(s => ({
|
| 1562 |
episodeIndex: s.episodeIndex,
|
| 1563 |
speed: s.totalMovement,
|
| 1564 |
}));
|
|
@@ -1568,16 +1802,20 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1568 |
if (!stateKey || stateDim === 0) return null;
|
| 1569 |
|
| 1570 |
let sNms: unknown = stateEntry![1].names;
|
| 1571 |
-
while (typeof sNms === "object" && sNms !== null && !Array.isArray(sNms))
|
|
|
|
| 1572 |
const stateNames = Array.isArray(sNms)
|
| 1573 |
? (sNms as string[])
|
| 1574 |
: Array.from({ length: stateDim }, (_, i) => `${i}`);
|
| 1575 |
-
const actionSuffixes = actionNames.map(n => {
|
|
|
|
|
|
|
|
|
|
| 1576 |
|
| 1577 |
// Match pairs by suffix, fall back to index
|
| 1578 |
const pairs: [number, number][] = [];
|
| 1579 |
for (let ai = 0; ai < actionDim; ai++) {
|
| 1580 |
-
const si = stateNames.findIndex(s => s === actionSuffixes[ai]);
|
| 1581 |
if (si >= 0) pairs.push([ai, si]);
|
| 1582 |
}
|
| 1583 |
if (pairs.length === 0) {
|
|
@@ -1600,64 +1838,113 @@ export async function loadCrossEpisodeActionVariance(
|
|
| 1600 |
|
| 1601 |
for (let pi = 0; pi < pairs.length; pi++) {
|
| 1602 |
const [ai, si] = pairs[pi];
|
| 1603 |
-
const aVals = actions.slice(0, n).map(r => r[ai] ?? 0);
|
| 1604 |
-
const sDeltas = Array.from(
|
|
|
|
|
|
|
|
|
|
| 1605 |
const effN = Math.min(aVals.length, sDeltas.length);
|
| 1606 |
const aM = aVals.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
|
| 1607 |
const sM = sDeltas.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
|
| 1608 |
|
| 1609 |
for (let li = 0; li < numLags; li++) {
|
| 1610 |
const lag = -maxLag + li;
|
| 1611 |
-
let sum = 0,
|
|
|
|
|
|
|
| 1612 |
for (let t = 0; t < effN; t++) {
|
| 1613 |
const sIdx = t + lag;
|
| 1614 |
if (sIdx < 0 || sIdx >= sDeltas.length) continue;
|
| 1615 |
-
const a = aVals[t] - aM,
|
| 1616 |
-
|
|
|
|
|
|
|
|
|
|
| 1617 |
}
|
| 1618 |
const d = Math.sqrt(aV * sV);
|
| 1619 |
-
if (d > 0) {
|
|
|
|
|
|
|
|
|
|
| 1620 |
}
|
| 1621 |
}
|
| 1622 |
}
|
| 1623 |
|
| 1624 |
const avgCorrs = pairs.map((_, pi) =>
|
| 1625 |
Array.from({ length: numLags }, (_, li) =>
|
| 1626 |
-
corrCounts[pi][li] > 0 ? corrSums[pi][li] / corrCounts[pi][li] : 0
|
| 1627 |
-
)
|
| 1628 |
);
|
| 1629 |
|
| 1630 |
const ccData = Array.from({ length: numLags }, (_, li) => {
|
| 1631 |
const lag = -maxLag + li;
|
| 1632 |
-
const vals = avgCorrs.map(pc => pc[li]);
|
| 1633 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1634 |
});
|
| 1635 |
|
| 1636 |
-
let meanPeakLag = 0,
|
| 1637 |
-
|
| 1638 |
-
let
|
|
|
|
|
|
|
|
|
|
| 1639 |
for (const row of ccData) {
|
| 1640 |
-
if (row.max > maxPeakCorr) {
|
| 1641 |
-
|
| 1642 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1643 |
}
|
| 1644 |
|
| 1645 |
-
const perPairPeakLags = avgCorrs.map(pc => {
|
| 1646 |
-
let best = -Infinity,
|
| 1647 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1648 |
return bestLag;
|
| 1649 |
});
|
| 1650 |
|
| 1651 |
return {
|
| 1652 |
-
ccData,
|
| 1653 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1654 |
};
|
| 1655 |
})();
|
| 1656 |
|
| 1657 |
return {
|
| 1658 |
-
actionNames,
|
| 1659 |
-
|
| 1660 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1661 |
};
|
| 1662 |
}
|
| 1663 |
|
|
@@ -1668,8 +1955,17 @@ export async function loadEpisodeFlatChartData(
|
|
| 1668 |
info: DatasetMetadata,
|
| 1669 |
episodeId: number,
|
| 1670 |
): Promise<Record<string, number>[]> {
|
| 1671 |
-
const episodeMetadata = await loadEpisodeMetadataV3Simple(
|
| 1672 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1673 |
return flatChartData;
|
| 1674 |
}
|
| 1675 |
|
|
|
|
| 5 |
readParquetAsObjects,
|
| 6 |
} from "@/utils/parquetUtils";
|
| 7 |
import { pick } from "@/utils/pick";
|
| 8 |
+
import {
|
| 9 |
+
getDatasetVersionAndInfo,
|
| 10 |
+
buildVersionedUrl,
|
| 11 |
+
} from "@/utils/versionUtils";
|
| 12 |
import { PADDING, CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
|
| 13 |
import {
|
| 14 |
processChartDataGroups,
|
|
|
|
| 124 |
}
|
| 125 |
|
| 126 |
console.time(`[perf] getEpisodeData (${version})`);
|
| 127 |
+
const result =
|
| 128 |
+
version === "v3.0"
|
| 129 |
+
? await getEpisodeDataV3(repoId, version, info, episodeId)
|
| 130 |
+
: await getEpisodeDataV2(repoId, version, info, episodeId);
|
| 131 |
console.timeEnd(`[perf] getEpisodeData (${version})`);
|
| 132 |
|
| 133 |
// Extract camera resolutions from features
|
|
|
|
| 140 |
robot_type: rawInfo.robot_type ?? null,
|
| 141 |
codebase_version: rawInfo.codebase_version,
|
| 142 |
total_tasks: rawInfo.total_tasks ?? 0,
|
| 143 |
+
dataset_size_mb:
|
| 144 |
+
Math.round(
|
| 145 |
+
((rawInfo.data_files_size_in_mb ?? 0) +
|
| 146 |
+
(rawInfo.video_files_size_in_mb ?? 0)) *
|
| 147 |
+
10,
|
| 148 |
+
) / 10,
|
| 149 |
cameras,
|
| 150 |
};
|
| 151 |
|
|
|
|
| 166 |
try {
|
| 167 |
const { version, info: rawInfo } = await getDatasetVersionAndInfo(repoId);
|
| 168 |
const info = rawInfo as unknown as DatasetMetadata;
|
| 169 |
+
|
| 170 |
const totalEpisodes = info.total_episodes;
|
| 171 |
const adjacentVideos: AdjacentEpisodeVideos[] = [];
|
| 172 |
+
|
| 173 |
// Calculate adjacent episode IDs
|
| 174 |
for (let offset = -radius; offset <= radius; offset++) {
|
| 175 |
if (offset === 0) continue; // Skip current episode
|
| 176 |
+
|
| 177 |
const episodeId = currentEpisodeId + offset;
|
| 178 |
if (episodeId >= 0 && episodeId < totalEpisodes) {
|
| 179 |
try {
|
| 180 |
let videosInfo: VideoInfo[] = [];
|
| 181 |
+
|
| 182 |
if (version === "v3.0") {
|
| 183 |
const episodeMetadata = await loadEpisodeMetadataV3Simple(
|
| 184 |
repoId,
|
|
|
|
| 194 |
} else {
|
| 195 |
// For v2.x, use simpler video info extraction
|
| 196 |
if (info.video_path) {
|
| 197 |
+
const episode_chunk = Math.floor(0 / 1000);
|
| 198 |
+
videosInfo = Object.entries(info.features)
|
| 199 |
+
.filter(([, value]) => value.dtype === "video")
|
| 200 |
+
.map(([key]) => {
|
| 201 |
const videoPath = formatStringWithVars(info.video_path!, {
|
| 202 |
+
video_key: key,
|
| 203 |
episode_chunk: episode_chunk
|
| 204 |
.toString()
|
| 205 |
.padStart(PADDING.CHUNK_INDEX, "0"),
|
| 206 |
episode_index: episodeId
|
| 207 |
.toString()
|
| 208 |
.padStart(PADDING.EPISODE_INDEX, "0"),
|
| 209 |
+
});
|
| 210 |
+
return {
|
| 211 |
+
filename: key,
|
| 212 |
+
url: buildVersionedUrl(repoId, version, videoPath),
|
| 213 |
+
};
|
| 214 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 215 |
}
|
| 216 |
}
|
| 217 |
+
|
| 218 |
adjacentVideos.push({ episodeId, videosInfo });
|
| 219 |
+
} catch {
|
| 220 |
+
// Skip failed episodes silently
|
| 221 |
+
}
|
| 222 |
}
|
| 223 |
}
|
| 224 |
+
|
| 225 |
return adjacentVideos;
|
| 226 |
} catch {
|
| 227 |
// Return empty array on error
|
|
|
|
| 262 |
.map((x) => parseInt(x.trim(), 10))
|
| 263 |
.filter((x) => !isNaN(x));
|
| 264 |
|
| 265 |
+
// Videos information
|
| 266 |
const videosInfo =
|
| 267 |
info.video_path !== null
|
| 268 |
? Object.entries(info.features)
|
| 269 |
+
.filter(([, value]) => value.dtype === "video")
|
| 270 |
+
.map(([key]) => {
|
| 271 |
const videoPath = formatStringWithVars(info.video_path!, {
|
| 272 |
+
video_key: key,
|
| 273 |
episode_chunk: episode_chunk
|
| 274 |
.toString()
|
| 275 |
.padStart(PADDING.CHUNK_INDEX, "0"),
|
| 276 |
episode_index: episodeId
|
| 277 |
.toString()
|
| 278 |
.padStart(PADDING.EPISODE_INDEX, "0"),
|
| 279 |
+
});
|
| 280 |
+
return {
|
| 281 |
+
filename: key,
|
| 282 |
+
url: buildVersionedUrl(repoId, version, videoPath),
|
| 283 |
+
};
|
| 284 |
})
|
| 285 |
: [];
|
| 286 |
|
|
|
|
| 306 |
return {
|
| 307 |
key,
|
| 308 |
value: Array.isArray(column_names)
|
| 309 |
+
? column_names.map(
|
| 310 |
+
(name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
|
| 311 |
+
)
|
| 312 |
: Array.from(
|
| 313 |
{ length: columnNames.find((c) => c.key === key)?.length ?? 1 },
|
| 314 |
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
|
|
|
| 329 |
|
| 330 |
const arrayBuffer = await fetchParquetFile(parquetUrl);
|
| 331 |
const allData = await readParquetAsObjects(arrayBuffer, []);
|
| 332 |
+
|
| 333 |
// Extract task from language_instruction fields, task field, or tasks.jsonl
|
| 334 |
let task: string | undefined;
|
| 335 |
+
|
| 336 |
if (allData.length > 0) {
|
| 337 |
const firstRow = allData[0];
|
| 338 |
const languageInstructions: string[] = [];
|
| 339 |
+
|
| 340 |
+
if (typeof firstRow.language_instruction === "string") {
|
| 341 |
languageInstructions.push(firstRow.language_instruction);
|
| 342 |
}
|
| 343 |
+
|
| 344 |
let instructionNum = 2;
|
| 345 |
+
while (
|
| 346 |
+
typeof firstRow[`language_instruction_${instructionNum}`] === "string"
|
| 347 |
+
) {
|
| 348 |
+
languageInstructions.push(
|
| 349 |
+
firstRow[`language_instruction_${instructionNum}`] as string,
|
| 350 |
+
);
|
| 351 |
instructionNum++;
|
| 352 |
}
|
| 353 |
+
|
| 354 |
if (languageInstructions.length > 0) {
|
| 355 |
+
task = languageInstructions.join("\n");
|
| 356 |
}
|
| 357 |
}
|
| 358 |
+
|
| 359 |
+
if (!task && allData.length > 0 && typeof allData[0].task === "string") {
|
| 360 |
task = allData[0].task;
|
| 361 |
}
|
| 362 |
+
|
| 363 |
if (!task && allData.length > 0) {
|
| 364 |
try {
|
| 365 |
const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
|
| 366 |
const tasksResponse = await fetch(tasksUrl);
|
| 367 |
+
|
| 368 |
if (tasksResponse.ok) {
|
| 369 |
const tasksText = await tasksResponse.text();
|
| 370 |
const tasksData = tasksText
|
| 371 |
.split("\n")
|
| 372 |
.filter((line) => line.trim())
|
| 373 |
.map((line) => JSON.parse(line));
|
| 374 |
+
|
| 375 |
if (tasksData && tasksData.length > 0) {
|
| 376 |
const taskIndex = allData[0].task_index;
|
| 377 |
+
const taskIndexNum =
|
| 378 |
+
typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
|
| 379 |
+
const taskData = tasksData.find(
|
| 380 |
+
(t: Record<string, unknown>) => t.task_index === taskIndexNum,
|
| 381 |
+
);
|
| 382 |
if (taskData) {
|
| 383 |
task = taskData.task;
|
| 384 |
}
|
|
|
|
| 388 |
// No tasks metadata file for this v2.x dataset
|
| 389 |
}
|
| 390 |
}
|
| 391 |
+
|
| 392 |
// Build chart data from already-parsed allData (no second parquet parse)
|
| 393 |
const seriesNames = [
|
| 394 |
"timestamp",
|
|
|
|
| 403 |
if (Array.isArray(rawVal)) {
|
| 404 |
rawVal.forEach((v: unknown, i: number) => {
|
| 405 |
if (i < col.value.length) obj[col.value[i]] = Number(v);
|
| 406 |
+
});
|
| 407 |
} else if (rawVal !== undefined) {
|
| 408 |
obj[col.value[0]] = Number(rawVal);
|
| 409 |
}
|
|
|
|
| 476 |
version,
|
| 477 |
episodeId,
|
| 478 |
);
|
| 479 |
+
|
| 480 |
// Create video info with segmentation using the metadata
|
| 481 |
const videosInfo = extractVideoInfoV3WithSegmentation(
|
| 482 |
repoId,
|
|
|
|
| 486 |
);
|
| 487 |
|
| 488 |
// Load episode data for charts
|
| 489 |
+
const { chartDataGroups, flatChartData, ignoredColumns, task } =
|
| 490 |
+
await loadEpisodeDataV3(repoId, version, info, episodeMetadata);
|
| 491 |
|
| 492 |
+
const duration = episodeMetadata.length
|
| 493 |
+
? episodeMetadata.length / info.fps
|
| 494 |
+
: episodeMetadata.video_to_timestamp - episodeMetadata.video_from_timestamp;
|
| 495 |
|
| 496 |
return {
|
| 497 |
datasetInfo,
|
|
|
|
| 512 |
version: string,
|
| 513 |
info: DatasetMetadata,
|
| 514 |
episodeMetadata: EpisodeMetadataV3,
|
| 515 |
+
): Promise<{
|
| 516 |
+
chartDataGroups: ChartRow[][];
|
| 517 |
+
flatChartData: Record<string, number>[];
|
| 518 |
+
ignoredColumns: string[];
|
| 519 |
+
task?: string;
|
| 520 |
+
}> {
|
| 521 |
// Build data file path using chunk and file indices
|
| 522 |
const dataChunkIndex = bigIntToNumber(episodeMetadata.data_chunk_index, 0);
|
| 523 |
const dataFileIndex = bigIntToNumber(episodeMetadata.data_file_index, 0);
|
| 524 |
const dataPath = buildV3DataPath(dataChunkIndex, dataFileIndex);
|
| 525 |
+
|
| 526 |
try {
|
| 527 |
const dataUrl = buildVersionedUrl(repoId, version, dataPath);
|
| 528 |
const arrayBuffer = await fetchParquetFile(dataUrl);
|
| 529 |
const fullData = await readParquetAsObjects(arrayBuffer, []);
|
| 530 |
+
|
| 531 |
// Extract the episode-specific data slice
|
| 532 |
const fromIndex = bigIntToNumber(episodeMetadata.dataset_from_index, 0);
|
| 533 |
+
const toIndex = bigIntToNumber(
|
| 534 |
+
episodeMetadata.dataset_to_index,
|
| 535 |
+
fullData.length,
|
| 536 |
+
);
|
| 537 |
+
|
| 538 |
// Find the starting index of this parquet file by checking the first row's index
|
| 539 |
// This handles the case where episodes are split across multiple parquet files
|
| 540 |
let fileStartIndex = 0;
|
| 541 |
if (fullData.length > 0 && fullData[0].index !== undefined) {
|
| 542 |
fileStartIndex = Number(fullData[0].index);
|
| 543 |
}
|
| 544 |
+
|
| 545 |
// Adjust indices to be relative to this file's starting position
|
| 546 |
const localFromIndex = Math.max(0, fromIndex - fileStartIndex);
|
| 547 |
const localToIndex = Math.min(fullData.length, toIndex - fileStartIndex);
|
| 548 |
+
|
| 549 |
const episodeData = fullData.slice(localFromIndex, localToIndex);
|
| 550 |
+
|
| 551 |
if (episodeData.length === 0) {
|
| 552 |
+
return {
|
| 553 |
+
chartDataGroups: [],
|
| 554 |
+
flatChartData: [],
|
| 555 |
+
ignoredColumns: [],
|
| 556 |
+
task: undefined,
|
| 557 |
+
};
|
| 558 |
}
|
| 559 |
+
|
| 560 |
// Convert to the same format as v2.x for compatibility with existing chart code
|
| 561 |
+
const { chartDataGroups, flatChartData, ignoredColumns } =
|
| 562 |
+
processEpisodeDataForCharts(episodeData, info, episodeMetadata);
|
| 563 |
+
|
| 564 |
// First check for language_instruction fields in the data (preferred)
|
| 565 |
let task: string | undefined;
|
| 566 |
if (episodeData.length > 0) {
|
| 567 |
const languageInstructions: string[] = [];
|
| 568 |
+
|
| 569 |
const extractInstructions = (row: Record<string, unknown>) => {
|
| 570 |
+
if (typeof row.language_instruction === "string") {
|
| 571 |
languageInstructions.push(row.language_instruction);
|
| 572 |
}
|
| 573 |
let num = 2;
|
| 574 |
+
while (typeof row[`language_instruction_${num}`] === "string") {
|
| 575 |
+
languageInstructions.push(
|
| 576 |
+
row[`language_instruction_${num}`] as string,
|
| 577 |
+
);
|
| 578 |
num++;
|
| 579 |
}
|
| 580 |
};
|
| 581 |
|
| 582 |
extractInstructions(episodeData[0]);
|
| 583 |
+
|
| 584 |
// If no instructions in first row, check middle and last rows
|
| 585 |
if (languageInstructions.length === 0 && episodeData.length > 1) {
|
| 586 |
+
for (const idx of [
|
| 587 |
+
Math.floor(episodeData.length / 2),
|
| 588 |
+
episodeData.length - 1,
|
| 589 |
+
]) {
|
| 590 |
extractInstructions(episodeData[idx]);
|
| 591 |
if (languageInstructions.length > 0) break;
|
| 592 |
}
|
| 593 |
}
|
| 594 |
+
|
| 595 |
if (languageInstructions.length > 0) {
|
| 596 |
+
task = languageInstructions.join("\n");
|
| 597 |
}
|
| 598 |
}
|
| 599 |
+
|
| 600 |
// Fall back to tasks metadata parquet
|
| 601 |
if (!task && episodeData.length > 0) {
|
| 602 |
try {
|
| 603 |
+
const tasksUrl = buildVersionedUrl(
|
| 604 |
+
repoId,
|
| 605 |
+
version,
|
| 606 |
+
"meta/tasks.parquet",
|
| 607 |
+
);
|
| 608 |
const tasksArrayBuffer = await fetchParquetFile(tasksUrl);
|
| 609 |
const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
|
| 610 |
+
|
| 611 |
if (tasksData.length > 0) {
|
| 612 |
const taskIndexNum = bigIntToNumber(episodeData[0].task_index, -1);
|
| 613 |
|
| 614 |
if (taskIndexNum >= 0 && taskIndexNum < tasksData.length) {
|
| 615 |
const taskData = tasksData[taskIndexNum];
|
| 616 |
const rawTask = taskData.__index_level_0__ ?? taskData.task;
|
| 617 |
+
task = typeof rawTask === "string" ? rawTask : undefined;
|
| 618 |
}
|
| 619 |
}
|
| 620 |
} catch {
|
| 621 |
// Could not load tasks metadata
|
| 622 |
}
|
| 623 |
}
|
| 624 |
+
|
| 625 |
return { chartDataGroups, flatChartData, ignoredColumns, task };
|
| 626 |
} catch {
|
| 627 |
+
return {
|
| 628 |
+
chartDataGroups: [],
|
| 629 |
+
flatChartData: [],
|
| 630 |
+
ignoredColumns: [],
|
| 631 |
+
task: undefined,
|
| 632 |
+
};
|
| 633 |
}
|
| 634 |
}
|
| 635 |
|
|
|
|
| 638 |
episodeData: Record<string, unknown>[],
|
| 639 |
info: DatasetMetadata,
|
| 640 |
episodeMetadata?: EpisodeMetadataV3,
|
| 641 |
+
): {
|
| 642 |
+
chartDataGroups: ChartRow[][];
|
| 643 |
+
flatChartData: Record<string, number>[];
|
| 644 |
+
ignoredColumns: string[];
|
| 645 |
+
} {
|
| 646 |
// Convert parquet data to chart format
|
| 647 |
let seriesNames: string[] = [];
|
| 648 |
+
|
| 649 |
// Dynamically create a mapping from numeric indices to feature names based on actual dataset features
|
| 650 |
const v3IndexToFeatureMap: Record<string, string> = {};
|
| 651 |
+
|
| 652 |
// Build mapping based on what features actually exist in the dataset
|
| 653 |
const featureKeys = Object.keys(info.features);
|
| 654 |
+
|
| 655 |
// Common feature order for v3.0 datasets (but only include if they exist)
|
| 656 |
const expectedFeatureOrder = [
|
| 657 |
"observation.state",
|
|
|
|
| 664 |
"index",
|
| 665 |
"task_index",
|
| 666 |
];
|
| 667 |
+
|
| 668 |
// Map indices to features that actually exist
|
| 669 |
let currentIndex = 0;
|
| 670 |
expectedFeatureOrder.forEach((feature) => {
|
|
|
|
| 673 |
currentIndex++;
|
| 674 |
}
|
| 675 |
});
|
| 676 |
+
|
| 677 |
// Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
|
| 678 |
const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
|
| 679 |
|
| 680 |
// Create columns structure similar to V2.1 for proper hierarchical naming
|
| 681 |
const columns: ColumnDef[] = Object.entries(info.features)
|
| 682 |
+
.filter(
|
| 683 |
+
([key, value]) =>
|
| 684 |
+
["float32", "int32"].includes(value.dtype) &&
|
| 685 |
+
value.shape.length === 1 &&
|
| 686 |
+
!excludedColumns.includes(key),
|
| 687 |
)
|
| 688 |
.map(([key, feature]) => {
|
| 689 |
let column_names: unknown = feature.names;
|
|
|
|
| 694 |
return {
|
| 695 |
key,
|
| 696 |
value: Array.isArray(column_names)
|
| 697 |
+
? column_names.map(
|
| 698 |
+
(name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
|
| 699 |
+
)
|
| 700 |
: Array.from(
|
| 701 |
{ length: feature.shape[0] || 1 },
|
| 702 |
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
|
|
|
| 708 |
if (episodeData.length > 0) {
|
| 709 |
const firstRow = episodeData[0];
|
| 710 |
const allKeys: string[] = [];
|
| 711 |
+
|
| 712 |
Object.entries(firstRow || {}).forEach(([key, value]) => {
|
| 713 |
if (key === "timestamp") return; // Skip timestamp, we'll add it separately
|
| 714 |
+
|
| 715 |
// Map numeric key to feature name if available
|
| 716 |
const featureName = v3IndexToFeatureMap[key] || key;
|
| 717 |
+
|
| 718 |
// Skip if feature doesn't exist in dataset
|
| 719 |
if (!info.features[featureName]) return;
|
| 720 |
+
|
| 721 |
// Skip excluded columns
|
| 722 |
if (excludedColumns.includes(featureName)) return;
|
| 723 |
+
|
| 724 |
// Find the matching column definition to get proper names
|
| 725 |
const columnDef = columns.find((col) => col.key === featureName);
|
| 726 |
if (columnDef && Array.isArray(value) && value.length > 0) {
|
|
|
|
| 738 |
allKeys.push(featureName);
|
| 739 |
}
|
| 740 |
});
|
| 741 |
+
|
| 742 |
seriesNames = ["timestamp", ...allKeys];
|
| 743 |
} else {
|
| 744 |
// Fallback to column-based approach like V2.1
|
|
|
|
| 747 |
|
| 748 |
const chartData = episodeData.map((row, index) => {
|
| 749 |
const obj: Record<string, number> = {};
|
| 750 |
+
|
| 751 |
// Add timestamp aligned with video timing
|
| 752 |
// For v3.0, we need to map the episode data index to the actual video duration
|
| 753 |
let videoDuration = episodeData.length; // Fallback to data length
|
|
|
|
| 759 |
}
|
| 760 |
obj["timestamp"] =
|
| 761 |
(index / Math.max(episodeData.length - 1, 1)) * videoDuration;
|
| 762 |
+
|
| 763 |
// Add all data columns using hierarchical naming
|
| 764 |
if (row && typeof row === "object") {
|
| 765 |
Object.entries(row).forEach(([key, value]) => {
|
|
|
|
| 767 |
// Timestamp is already handled above
|
| 768 |
return;
|
| 769 |
}
|
| 770 |
+
|
| 771 |
// Map numeric key to feature name if available
|
| 772 |
const featureName = v3IndexToFeatureMap[key] || key;
|
| 773 |
+
|
| 774 |
// Skip if feature doesn't exist in dataset
|
| 775 |
if (!info.features[featureName]) return;
|
| 776 |
+
|
| 777 |
// Skip excluded columns
|
| 778 |
if (excludedColumns.includes(featureName)) return;
|
| 779 |
+
|
| 780 |
// Find the matching column definition to get proper series names
|
| 781 |
const columnDef = columns.find((col) => col.key === featureName);
|
| 782 |
+
|
| 783 |
if (Array.isArray(value) && columnDef) {
|
| 784 |
// For array values like observation.state and action, use proper hierarchical naming
|
| 785 |
value.forEach((val, idx) => {
|
|
|
|
| 798 |
}
|
| 799 |
});
|
| 800 |
}
|
| 801 |
+
|
| 802 |
return obj;
|
| 803 |
});
|
| 804 |
|
|
|
|
| 848 |
const cameraSpecificKeys = Object.keys(episodeMetadata).filter((key) =>
|
| 849 |
key.startsWith(`videos/${videoKey}/`),
|
| 850 |
);
|
| 851 |
+
|
| 852 |
+
let chunkIndex: number,
|
| 853 |
+
fileIndex: number,
|
| 854 |
+
segmentStart: number,
|
| 855 |
+
segmentEnd: number;
|
| 856 |
+
|
| 857 |
+
const toNum = (v: string | number): number =>
|
| 858 |
+
typeof v === "string" ? parseFloat(v) || 0 : v;
|
| 859 |
|
| 860 |
if (cameraSpecificKeys.length > 0) {
|
| 861 |
chunkIndex = toNum(episodeMetadata[`videos/${videoKey}/chunk_index`]);
|
| 862 |
fileIndex = toNum(episodeMetadata[`videos/${videoKey}/file_index`]);
|
| 863 |
+
segmentStart =
|
| 864 |
+
toNum(episodeMetadata[`videos/${videoKey}/from_timestamp`]) || 0;
|
| 865 |
+
segmentEnd =
|
| 866 |
+
toNum(episodeMetadata[`videos/${videoKey}/to_timestamp`]) || 30;
|
| 867 |
} else {
|
| 868 |
chunkIndex = episodeMetadata.video_chunk_index || 0;
|
| 869 |
fileIndex = episodeMetadata.video_file_index || 0;
|
| 870 |
segmentStart = episodeMetadata.video_from_timestamp || 0;
|
| 871 |
segmentEnd = episodeMetadata.video_to_timestamp || 30;
|
| 872 |
}
|
| 873 |
+
|
| 874 |
// Convert BigInt to number for timestamps
|
| 875 |
const startNum = bigIntToNumber(segmentStart);
|
| 876 |
const endNum = bigIntToNumber(segmentEnd);
|
|
|
|
| 881 |
bigIntToNumber(fileIndex, 0),
|
| 882 |
);
|
| 883 |
const fullUrl = buildVersionedUrl(repoId, version, videoPath);
|
| 884 |
+
|
| 885 |
return {
|
| 886 |
filename: videoKey,
|
| 887 |
url: fullUrl,
|
|
|
|
| 904 |
): Promise<EpisodeMetadataV3> {
|
| 905 |
// Pattern: meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet
|
| 906 |
// Most datasets have all episodes in chunk-000/file-000, but episodes can be split across files
|
| 907 |
+
|
| 908 |
let episodeRow = null;
|
| 909 |
let fileIndex = 0;
|
| 910 |
const chunkIndex = 0; // Episodes are typically in chunk-000
|
| 911 |
+
|
| 912 |
// Try loading episode metadata files until we find the episode
|
| 913 |
while (!episodeRow) {
|
| 914 |
const episodesMetadataPath = buildV3EpisodesMetadataPath(
|
|
|
|
| 924 |
try {
|
| 925 |
const arrayBuffer = await fetchParquetFile(episodesMetadataUrl);
|
| 926 |
const episodesData = await readParquetAsObjects(arrayBuffer, []);
|
| 927 |
+
|
| 928 |
if (episodesData.length === 0) {
|
| 929 |
// Empty file, try next one
|
| 930 |
fileIndex++;
|
| 931 |
continue;
|
| 932 |
}
|
| 933 |
+
|
| 934 |
// Find the row for the requested episode by episode_index
|
| 935 |
for (const row of episodesData) {
|
| 936 |
const parsedRow = parseEpisodeRowSimple(row);
|
| 937 |
+
|
| 938 |
if (parsedRow.episode_index === episodeId) {
|
| 939 |
episodeRow = row;
|
| 940 |
break;
|
| 941 |
}
|
| 942 |
}
|
| 943 |
+
|
| 944 |
if (!episodeRow) {
|
| 945 |
// Not in this file, try the next one
|
| 946 |
fileIndex++;
|
|
|
|
| 952 |
);
|
| 953 |
}
|
| 954 |
}
|
| 955 |
+
|
| 956 |
// Convert the row to a usable format
|
| 957 |
return parseEpisodeRowSimple(episodeRow);
|
| 958 |
}
|
| 959 |
|
| 960 |
// Simple parser for episode row - focuses on key fields for episodes
|
| 961 |
+
function parseEpisodeRowSimple(
|
| 962 |
+
row: Record<string, unknown>,
|
| 963 |
+
): EpisodeMetadataV3 {
|
| 964 |
// v3.0 uses named keys in the episode metadata
|
| 965 |
if (row && typeof row === "object") {
|
| 966 |
// Check if this is v3.0 format with named keys
|
|
|
|
| 968 |
// v3.0 format - use named keys
|
| 969 |
// Convert BigInt values to numbers
|
| 970 |
const toBigIntSafe = (value: unknown): number => {
|
| 971 |
+
if (typeof value === "bigint") return Number(value);
|
| 972 |
+
if (typeof value === "number") return value;
|
| 973 |
+
if (typeof value === "string") return parseInt(value) || 0;
|
| 974 |
return 0;
|
| 975 |
};
|
| 976 |
+
|
| 977 |
const toNumSafe = (value: unknown): number => {
|
| 978 |
+
if (typeof value === "number") return value;
|
| 979 |
+
if (typeof value === "bigint") return Number(value);
|
| 980 |
+
if (typeof value === "string") return parseFloat(value) || 0;
|
| 981 |
return 0;
|
| 982 |
};
|
| 983 |
|
| 984 |
// Handle video metadata - look for video-specific keys
|
| 985 |
+
const videoKeys = Object.keys(row).filter(
|
| 986 |
+
(key) => key.includes("videos/") && key.includes("/chunk_index"),
|
| 987 |
+
);
|
| 988 |
+
let videoChunkIndex = 0,
|
| 989 |
+
videoFileIndex = 0,
|
| 990 |
+
videoFromTs = 0,
|
| 991 |
+
videoToTs = 30;
|
| 992 |
if (videoKeys.length > 0) {
|
| 993 |
+
const videoBaseName = videoKeys[0].replace("/chunk_index", "");
|
| 994 |
videoChunkIndex = toBigIntSafe(row[`${videoBaseName}/chunk_index`]);
|
| 995 |
videoFileIndex = toBigIntSafe(row[`${videoBaseName}/file_index`]);
|
| 996 |
videoFromTs = toNumSafe(row[`${videoBaseName}/from_timestamp`]);
|
|
|
|
| 998 |
}
|
| 999 |
|
| 1000 |
const episodeData: EpisodeMetadataV3 = {
|
| 1001 |
+
episode_index: toBigIntSafe(row["episode_index"]),
|
| 1002 |
+
data_chunk_index: toBigIntSafe(row["data/chunk_index"]),
|
| 1003 |
+
data_file_index: toBigIntSafe(row["data/file_index"]),
|
| 1004 |
+
dataset_from_index: toBigIntSafe(row["dataset_from_index"]),
|
| 1005 |
+
dataset_to_index: toBigIntSafe(row["dataset_to_index"]),
|
| 1006 |
+
length: toBigIntSafe(row["length"]),
|
| 1007 |
video_chunk_index: videoChunkIndex,
|
| 1008 |
video_file_index: videoFileIndex,
|
| 1009 |
video_from_timestamp: videoFromTs,
|
| 1010 |
video_to_timestamp: videoToTs,
|
| 1011 |
};
|
| 1012 |
+
|
| 1013 |
// Store per-camera metadata for extractVideoInfoV3WithSegmentation
|
| 1014 |
+
Object.keys(row).forEach((key) => {
|
| 1015 |
+
if (key.startsWith("videos/")) {
|
| 1016 |
const val = row[key];
|
| 1017 |
+
episodeData[key] =
|
| 1018 |
+
typeof val === "bigint"
|
| 1019 |
+
? Number(val)
|
| 1020 |
+
: typeof val === "number" || typeof val === "string"
|
| 1021 |
+
? val
|
| 1022 |
+
: 0;
|
| 1023 |
}
|
| 1024 |
});
|
| 1025 |
+
|
| 1026 |
return episodeData as EpisodeMetadataV3;
|
| 1027 |
} else {
|
| 1028 |
// Fallback to numeric keys for compatibility
|
| 1029 |
const toNum = (v: unknown, fallback = 0): number =>
|
| 1030 |
+
typeof v === "number"
|
| 1031 |
+
? v
|
| 1032 |
+
: typeof v === "bigint"
|
| 1033 |
+
? Number(v)
|
| 1034 |
+
: fallback;
|
| 1035 |
return {
|
| 1036 |
+
episode_index: toNum(row["0"]),
|
| 1037 |
+
data_chunk_index: toNum(row["1"]),
|
| 1038 |
+
data_file_index: toNum(row["2"]),
|
| 1039 |
+
dataset_from_index: toNum(row["3"]),
|
| 1040 |
+
dataset_to_index: toNum(row["4"]),
|
| 1041 |
+
video_chunk_index: toNum(row["5"]),
|
| 1042 |
+
video_file_index: toNum(row["6"]),
|
| 1043 |
+
video_from_timestamp: toNum(row["7"]),
|
| 1044 |
+
video_to_timestamp: toNum(row["8"], 30),
|
| 1045 |
+
length: toNum(row["9"], 30),
|
| 1046 |
};
|
| 1047 |
}
|
| 1048 |
}
|
| 1049 |
+
|
| 1050 |
// Fallback if parsing fails
|
| 1051 |
const fallback = {
|
| 1052 |
episode_index: 0,
|
|
|
|
| 1060 |
video_to_timestamp: 30,
|
| 1061 |
length: 30,
|
| 1062 |
};
|
| 1063 |
+
|
| 1064 |
return fallback;
|
| 1065 |
}
|
| 1066 |
|
|
|
|
|
|
|
| 1067 |
// ─── Stats computation ───────────────────────────────────────────
|
| 1068 |
|
| 1069 |
/**
|
| 1070 |
* Compute per-column min/max values from the current episode's chart data.
|
| 1071 |
*/
|
| 1072 |
+
export function computeColumnMinMax(
|
| 1073 |
+
chartDataGroups: ChartRow[][],
|
| 1074 |
+
): ColumnMinMax[] {
|
| 1075 |
const stats: Record<string, { min: number; max: number }> = {};
|
| 1076 |
|
| 1077 |
for (const group of chartDataGroups) {
|
|
|
|
| 1133 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1134 |
for (const row of rows) {
|
| 1135 |
const parsed = parseEpisodeRowSimple(row);
|
| 1136 |
+
allEpisodes.push({
|
| 1137 |
+
index: parsed.episode_index,
|
| 1138 |
+
length: parsed.length,
|
| 1139 |
+
});
|
| 1140 |
}
|
| 1141 |
fileIndex++;
|
| 1142 |
} catch {
|
|
|
|
| 1152 |
lengthSeconds: Math.round((ep.length / fps) * 100) / 100,
|
| 1153 |
}));
|
| 1154 |
|
| 1155 |
+
const sortedByLength = [...withSeconds].sort(
|
| 1156 |
+
(a, b) => a.lengthSeconds - b.lengthSeconds,
|
| 1157 |
+
);
|
| 1158 |
const shortestEpisodes = sortedByLength.slice(0, 5);
|
| 1159 |
const longestEpisodes = sortedByLength.slice(-5).reverse();
|
| 1160 |
|
|
|
|
| 1164 |
|
| 1165 |
const sorted = [...lengths].sort((a, b) => a - b);
|
| 1166 |
const mid = Math.floor(sorted.length / 2);
|
| 1167 |
+
const median =
|
| 1168 |
+
sorted.length % 2 === 0
|
| 1169 |
+
? Math.round(((sorted[mid - 1] + sorted[mid]) / 2) * 100) / 100
|
| 1170 |
+
: sorted[mid];
|
| 1171 |
|
| 1172 |
+
const variance =
|
| 1173 |
+
lengths.reduce((acc, l) => acc + (l - mean) ** 2, 0) / lengths.length;
|
| 1174 |
const std = Math.round(Math.sqrt(variance) * 100) / 100;
|
| 1175 |
|
| 1176 |
// Build histogram
|
|
|
|
| 1179 |
|
| 1180 |
if (histMax === histMin) {
|
| 1181 |
return {
|
| 1182 |
+
shortestEpisodes,
|
| 1183 |
+
longestEpisodes,
|
| 1184 |
+
allEpisodeLengths: withSeconds,
|
| 1185 |
+
meanEpisodeLength: mean,
|
| 1186 |
+
medianEpisodeLength: median,
|
| 1187 |
+
stdEpisodeLength: std,
|
| 1188 |
+
episodeLengthHistogram: [
|
| 1189 |
+
{ binLabel: `${histMin.toFixed(1)}s`, count: lengths.length },
|
| 1190 |
+
],
|
| 1191 |
};
|
| 1192 |
}
|
| 1193 |
|
| 1194 |
const p1 = sorted[Math.floor(sorted.length * 0.01)];
|
| 1195 |
const p99 = sorted[Math.ceil(sorted.length * 0.99) - 1];
|
| 1196 |
+
const range = p99 - p1 || 1;
|
| 1197 |
|
| 1198 |
+
const targetBins = Math.max(
|
| 1199 |
+
10,
|
| 1200 |
+
Math.min(50, Math.ceil(Math.log2(lengths.length) + 1)),
|
| 1201 |
+
);
|
| 1202 |
const rawBinWidth = range / targetBins;
|
| 1203 |
const magnitude = Math.pow(10, Math.floor(Math.log10(rawBinWidth)));
|
| 1204 |
const niceSteps = [1, 2, 2.5, 5, 10];
|
| 1205 |
+
const niceBinWidth =
|
| 1206 |
+
niceSteps.map((s) => s * magnitude).find((w) => w >= rawBinWidth) ??
|
| 1207 |
+
rawBinWidth;
|
| 1208 |
|
| 1209 |
const niceMin = Math.floor(p1 / niceBinWidth) * niceBinWidth;
|
| 1210 |
const niceMax = Math.ceil(p99 / niceBinWidth) * niceBinWidth;
|
| 1211 |
+
const actualBinCount = Math.max(
|
| 1212 |
+
1,
|
| 1213 |
+
Math.round((niceMax - niceMin) / niceBinWidth),
|
| 1214 |
+
);
|
| 1215 |
const bins = Array.from({ length: actualBinCount }, () => 0);
|
| 1216 |
|
| 1217 |
for (const len of lengths) {
|
|
|
|
| 1228 |
});
|
| 1229 |
|
| 1230 |
return {
|
| 1231 |
+
shortestEpisodes,
|
| 1232 |
+
longestEpisodes,
|
| 1233 |
+
allEpisodeLengths: withSeconds,
|
| 1234 |
+
meanEpisodeLength: mean,
|
| 1235 |
+
medianEpisodeLength: median,
|
| 1236 |
+
stdEpisodeLength: std,
|
| 1237 |
episodeLengthHistogram: histogram,
|
| 1238 |
};
|
| 1239 |
} catch {
|
|
|
|
| 1250 |
version: string,
|
| 1251 |
info: DatasetMetadata,
|
| 1252 |
): Promise<EpisodeFramesData> {
|
| 1253 |
+
const videoFeatures = Object.entries(info.features).filter(
|
| 1254 |
+
([, f]) => f.dtype === "video",
|
| 1255 |
+
);
|
| 1256 |
if (videoFeatures.length === 0) return { cameras: [], framesByCamera: {} };
|
| 1257 |
|
| 1258 |
const cameras = videoFeatures.map(([key]) => key);
|
|
|
|
| 1264 |
while (true) {
|
| 1265 |
const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1266 |
try {
|
| 1267 |
+
const buf = await fetchParquetFile(
|
| 1268 |
+
buildVersionedUrl(repoId, version, path),
|
| 1269 |
+
);
|
| 1270 |
const rows = await readParquetAsObjects(buf, []);
|
| 1271 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1272 |
for (const row of rows) {
|
| 1273 |
const epIdx = Number(row["episode_index"] ?? 0);
|
| 1274 |
for (const cam of cameras) {
|
| 1275 |
+
const cIdx = Number(
|
| 1276 |
+
row[`videos/${cam}/chunk_index`] ?? row["video_chunk_index"] ?? 0,
|
| 1277 |
+
);
|
| 1278 |
+
const fIdx = Number(
|
| 1279 |
+
row[`videos/${cam}/file_index`] ?? row["video_file_index"] ?? 0,
|
| 1280 |
+
);
|
| 1281 |
+
const fromTs = Number(
|
| 1282 |
+
row[`videos/${cam}/from_timestamp`] ??
|
| 1283 |
+
row["video_from_timestamp"] ??
|
| 1284 |
+
0,
|
| 1285 |
+
);
|
| 1286 |
+
const toTs = Number(
|
| 1287 |
+
row[`videos/${cam}/to_timestamp`] ??
|
| 1288 |
+
row["video_to_timestamp"] ??
|
| 1289 |
+
30,
|
| 1290 |
+
);
|
| 1291 |
const videoPath = `videos/${cam}/chunk-${cIdx.toString().padStart(3, "0")}/file-${fIdx.toString().padStart(3, "0")}.mp4`;
|
| 1292 |
framesByCamera[cam].push({
|
| 1293 |
episodeIndex: epIdx,
|
|
|
|
| 1327 |
|
| 1328 |
// ─── Cross-episode action variance ──────────────────────────────
|
| 1329 |
|
| 1330 |
+
export type LowMovementEpisode = {
|
| 1331 |
+
episodeIndex: number;
|
| 1332 |
+
totalMovement: number;
|
| 1333 |
+
};
|
| 1334 |
|
| 1335 |
export type AggVelocityStat = {
|
| 1336 |
name: string;
|
|
|
|
| 1391 |
maxEpisodes = 500,
|
| 1392 |
numTimeBins = 50,
|
| 1393 |
): Promise<CrossEpisodeVarianceData | null> {
|
| 1394 |
+
const actionEntry = Object.entries(info.features).find(
|
| 1395 |
+
([key, f]) => key === "action" && f.shape.length === 1,
|
| 1396 |
+
);
|
| 1397 |
if (!actionEntry) {
|
| 1398 |
+
console.warn(
|
| 1399 |
+
"[cross-ep] No action feature found. Available features:",
|
| 1400 |
+
Object.entries(info.features)
|
| 1401 |
+
.map(([k, f]) => `${k}(${f.dtype}, shape=${JSON.stringify(f.shape)})`)
|
| 1402 |
+
.join(", "),
|
| 1403 |
+
);
|
| 1404 |
return null;
|
| 1405 |
}
|
| 1406 |
|
|
|
|
| 1412 |
names = Object.values(names)[0];
|
| 1413 |
}
|
| 1414 |
const actionNames = Array.isArray(names)
|
| 1415 |
+
? (names as string[]).map((n) => `${actionKey}${SERIES_NAME_DELIMITER}${n}`)
|
| 1416 |
+
: Array.from(
|
| 1417 |
+
{ length: actionDim },
|
| 1418 |
+
(_, i) => `${actionKey}${SERIES_NAME_DELIMITER}${i}`,
|
| 1419 |
+
);
|
| 1420 |
|
| 1421 |
// State feature for alignment computation
|
| 1422 |
+
const stateEntry = Object.entries(info.features).find(
|
| 1423 |
+
([key, f]) => key === "observation.state" && f.shape.length === 1,
|
| 1424 |
+
);
|
| 1425 |
const stateKey = stateEntry?.[0] ?? null;
|
| 1426 |
const stateDim = stateEntry?.[1].shape[0] ?? 0;
|
| 1427 |
|
| 1428 |
// Collect episode metadata
|
| 1429 |
+
type EpMeta = {
|
| 1430 |
+
index: number;
|
| 1431 |
+
chunkIdx: number;
|
| 1432 |
+
fileIdx: number;
|
| 1433 |
+
from: number;
|
| 1434 |
+
to: number;
|
| 1435 |
+
};
|
| 1436 |
const allEps: EpMeta[] = [];
|
| 1437 |
|
| 1438 |
if (version === "v3.0") {
|
|
|
|
| 1440 |
while (true) {
|
| 1441 |
const path = `meta/episodes/chunk-000/file-${fileIndex.toString().padStart(3, "0")}.parquet`;
|
| 1442 |
try {
|
| 1443 |
+
const buf = await fetchParquetFile(
|
| 1444 |
+
buildVersionedUrl(repoId, version, path),
|
| 1445 |
+
);
|
| 1446 |
const rows = await readParquetAsObjects(buf, []);
|
| 1447 |
if (rows.length === 0 && fileIndex > 0) break;
|
| 1448 |
for (const row of rows) {
|
|
|
|
| 1456 |
});
|
| 1457 |
}
|
| 1458 |
fileIndex++;
|
| 1459 |
+
} catch {
|
| 1460 |
+
break;
|
| 1461 |
+
}
|
| 1462 |
}
|
| 1463 |
} else {
|
| 1464 |
for (let i = 0; i < info.total_episodes; i++) {
|
|
|
|
| 1467 |
}
|
| 1468 |
|
| 1469 |
if (allEps.length < 2) {
|
| 1470 |
+
console.warn(
|
| 1471 |
+
`[cross-ep] Only ${allEps.length} episode(s) found in metadata, need ≥2`,
|
| 1472 |
+
);
|
| 1473 |
return null;
|
| 1474 |
}
|
| 1475 |
+
console.log(
|
| 1476 |
+
`[cross-ep] Found ${allEps.length} episodes in metadata, sampling up to ${maxEpisodes}`,
|
| 1477 |
+
);
|
| 1478 |
|
| 1479 |
// Sample episodes evenly
|
| 1480 |
+
const sampled =
|
| 1481 |
+
allEps.length <= maxEpisodes
|
| 1482 |
+
? allEps
|
| 1483 |
+
: Array.from(
|
| 1484 |
+
{ length: maxEpisodes },
|
| 1485 |
+
(_, i) =>
|
| 1486 |
+
allEps[Math.round((i * (allEps.length - 1)) / (maxEpisodes - 1))],
|
| 1487 |
+
);
|
| 1488 |
|
| 1489 |
// Load action (and state) data per episode
|
| 1490 |
const episodeActions: { index: number; actions: number[][] }[] = [];
|
|
|
|
| 1502 |
const ep0 = eps[0];
|
| 1503 |
const dataPath = `data/chunk-${ep0.chunkIdx.toString().padStart(3, "0")}/file-${ep0.fileIdx.toString().padStart(3, "0")}.parquet`;
|
| 1504 |
try {
|
| 1505 |
+
const buf = await fetchParquetFile(
|
| 1506 |
+
buildVersionedUrl(repoId, version, dataPath),
|
| 1507 |
+
);
|
| 1508 |
const rows = await readParquetAsObjects(buf, []);
|
| 1509 |
+
const fileStart =
|
| 1510 |
+
rows.length > 0 && rows[0].index !== undefined
|
| 1511 |
+
? Number(rows[0].index)
|
| 1512 |
+
: 0;
|
| 1513 |
|
| 1514 |
for (const ep of eps) {
|
| 1515 |
const localFrom = Math.max(0, ep.from - fileStart);
|
|
|
|
| 1526 |
}
|
| 1527 |
if (actions.length > 0) {
|
| 1528 |
episodeActions.push({ index: ep.index, actions });
|
| 1529 |
+
episodeStates.push(
|
| 1530 |
+
stateKey && states.length === actions.length ? states : null,
|
| 1531 |
+
);
|
| 1532 |
}
|
| 1533 |
}
|
| 1534 |
+
} catch {
|
| 1535 |
+
/* skip file */
|
| 1536 |
+
}
|
| 1537 |
}
|
| 1538 |
} else {
|
| 1539 |
const chunkSize = info.chunks_size || 1000;
|
|
|
|
| 1544 |
episode_index: ep.index.toString().padStart(6, "0"),
|
| 1545 |
});
|
| 1546 |
try {
|
| 1547 |
+
const buf = await fetchParquetFile(
|
| 1548 |
+
buildVersionedUrl(repoId, version, dataPath),
|
| 1549 |
+
);
|
| 1550 |
const rows = await readParquetAsObjects(buf, []);
|
| 1551 |
const actions: number[][] = [];
|
| 1552 |
const states: number[][] = [];
|
|
|
|
| 1569 |
}
|
| 1570 |
if (actions.length > 0) {
|
| 1571 |
episodeActions.push({ index: ep.index, actions });
|
| 1572 |
+
episodeStates.push(
|
| 1573 |
+
stateKey && states.length === actions.length ? states : null,
|
| 1574 |
+
);
|
| 1575 |
}
|
| 1576 |
+
} catch {
|
| 1577 |
+
/* skip */
|
| 1578 |
+
}
|
| 1579 |
}
|
| 1580 |
}
|
| 1581 |
|
| 1582 |
if (episodeActions.length < 2) {
|
| 1583 |
+
console.warn(
|
| 1584 |
+
`[cross-ep] Only ${episodeActions.length} episode(s) had loadable action data out of ${sampled.length} sampled`,
|
| 1585 |
+
);
|
| 1586 |
return null;
|
| 1587 |
}
|
| 1588 |
+
console.log(
|
| 1589 |
+
`[cross-ep] Loaded action data for ${episodeActions.length}/${sampled.length} episodes`,
|
| 1590 |
+
);
|
| 1591 |
|
| 1592 |
// Resample each episode to numTimeBins and compute variance
|
| 1593 |
+
const timeBins = Array.from(
|
| 1594 |
+
{ length: numTimeBins },
|
| 1595 |
+
(_, i) => i / (numTimeBins - 1),
|
| 1596 |
+
);
|
| 1597 |
+
const sums = Array.from(
|
| 1598 |
+
{ length: numTimeBins },
|
| 1599 |
+
() => new Float64Array(actionDim),
|
| 1600 |
+
);
|
| 1601 |
+
const sumsSq = Array.from(
|
| 1602 |
+
{ length: numTimeBins },
|
| 1603 |
+
() => new Float64Array(actionDim),
|
| 1604 |
+
);
|
| 1605 |
const counts = new Uint32Array(numTimeBins);
|
| 1606 |
|
| 1607 |
for (const { actions: epActions } of episodeActions) {
|
|
|
|
| 1623 |
const row: number[] = [];
|
| 1624 |
const n = counts[b];
|
| 1625 |
for (let d = 0; d < actionDim; d++) {
|
| 1626 |
+
if (n < 2) {
|
| 1627 |
+
row.push(0);
|
| 1628 |
+
continue;
|
| 1629 |
+
}
|
| 1630 |
const mean = sums[b][d] / n;
|
| 1631 |
row.push(sumsSq[b][d] / n - mean * mean);
|
| 1632 |
}
|
|
|
|
| 1634 |
}
|
| 1635 |
|
| 1636 |
// Per-episode average movement per frame: mean L2 norm of frame-to-frame action deltas
|
| 1637 |
+
const movementScores: LowMovementEpisode[] = episodeActions.map(
|
| 1638 |
+
({ index, actions: ep }) => {
|
| 1639 |
+
if (ep.length < 2) return { episodeIndex: index, totalMovement: 0 };
|
| 1640 |
+
let total = 0;
|
| 1641 |
+
for (let t = 1; t < ep.length; t++) {
|
| 1642 |
+
let sumSq = 0;
|
| 1643 |
+
for (let d = 0; d < actionDim; d++) {
|
| 1644 |
+
const delta = (ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0);
|
| 1645 |
+
sumSq += delta * delta;
|
| 1646 |
+
}
|
| 1647 |
+
total += Math.sqrt(sumSq);
|
| 1648 |
}
|
| 1649 |
+
const avgPerFrame = total / (ep.length - 1);
|
| 1650 |
+
return {
|
| 1651 |
+
episodeIndex: index,
|
| 1652 |
+
totalMovement: Math.round(avgPerFrame * 10000) / 10000,
|
| 1653 |
+
};
|
| 1654 |
+
},
|
| 1655 |
+
);
|
| 1656 |
|
| 1657 |
movementScores.sort((a, b) => a.totalMovement - b.totalMovement);
|
| 1658 |
const lowMovementEpisodes = movementScores.slice(0, 10);
|
| 1659 |
|
| 1660 |
// Aggregated velocity stats: pool deltas from all episodes
|
| 1661 |
+
const shortName = (k: string) => {
|
| 1662 |
+
const p = k.split(SERIES_NAME_DELIMITER);
|
| 1663 |
+
return p.length > 1 ? p[p.length - 1] : k;
|
| 1664 |
+
};
|
| 1665 |
|
| 1666 |
const aggVelocity: AggVelocityStat[] = (() => {
|
| 1667 |
const binCount = 30;
|
|
|
|
| 1672 |
deltas.push((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
|
| 1673 |
}
|
| 1674 |
}
|
| 1675 |
+
if (deltas.length === 0)
|
| 1676 |
+
return {
|
| 1677 |
+
name: shortName(actionNames[d]),
|
| 1678 |
+
std: 0,
|
| 1679 |
+
maxAbs: 0,
|
| 1680 |
+
bins: [],
|
| 1681 |
+
lo: 0,
|
| 1682 |
+
hi: 0,
|
| 1683 |
+
};
|
| 1684 |
+
let sum = 0,
|
| 1685 |
+
maxAbs = 0,
|
| 1686 |
+
lo = Infinity,
|
| 1687 |
+
hi = -Infinity;
|
| 1688 |
+
for (const v of deltas) {
|
| 1689 |
+
sum += v;
|
| 1690 |
+
const a = Math.abs(v);
|
| 1691 |
+
if (a > maxAbs) maxAbs = a;
|
| 1692 |
+
if (v < lo) lo = v;
|
| 1693 |
+
if (v > hi) hi = v;
|
| 1694 |
+
}
|
| 1695 |
const mean = sum / deltas.length;
|
| 1696 |
+
let varSum = 0;
|
| 1697 |
+
for (const v of deltas) varSum += (v - mean) ** 2;
|
| 1698 |
const std = Math.sqrt(varSum / deltas.length);
|
| 1699 |
const range = hi - lo || 1;
|
| 1700 |
const binW = range / binCount;
|
| 1701 |
const bins = new Array(binCount).fill(0);
|
| 1702 |
+
for (const v of deltas) {
|
| 1703 |
+
let b = Math.floor((v - lo) / binW);
|
| 1704 |
+
if (b >= binCount) b = binCount - 1;
|
| 1705 |
+
bins[b]++;
|
| 1706 |
+
}
|
| 1707 |
return { name: shortName(actionNames[d]), std, maxAbs, bins, lo, hi };
|
| 1708 |
});
|
| 1709 |
})();
|
| 1710 |
|
| 1711 |
// Aggregated autocorrelation: average per-episode ACFs
|
| 1712 |
const aggAutocorrelation: AggAutocorrelation | null = (() => {
|
| 1713 |
+
const maxLag = Math.min(
|
| 1714 |
+
100,
|
| 1715 |
+
Math.floor(
|
| 1716 |
+
episodeActions.reduce(
|
| 1717 |
+
(min, e) => Math.min(min, e.actions.length),
|
| 1718 |
+
Infinity,
|
| 1719 |
+
) / 2,
|
| 1720 |
+
),
|
| 1721 |
+
);
|
| 1722 |
if (maxLag < 2) return null;
|
| 1723 |
|
| 1724 |
+
const avgAcf: number[][] = Array.from({ length: actionDim }, () =>
|
| 1725 |
+
new Array(maxLag).fill(0),
|
| 1726 |
+
);
|
| 1727 |
let epCount = 0;
|
| 1728 |
|
| 1729 |
for (const { actions: ep } of episodeActions) {
|
| 1730 |
if (ep.length < maxLag * 2) continue;
|
| 1731 |
epCount++;
|
| 1732 |
for (let d = 0; d < actionDim; d++) {
|
| 1733 |
+
const vals = ep.map((row) => row[d] ?? 0);
|
| 1734 |
const n = vals.length;
|
| 1735 |
const m = vals.reduce((a, b) => a + b, 0) / n;
|
| 1736 |
+
const centered = vals.map((v) => v - m);
|
| 1737 |
const vari = centered.reduce((a, v) => a + v * v, 0);
|
| 1738 |
if (vari === 0) continue;
|
| 1739 |
for (let lag = 1; lag <= maxLag; lag++) {
|
| 1740 |
let s = 0;
|
| 1741 |
+
for (let t = 0; t < n - lag; t++)
|
| 1742 |
+
s += centered[t] * centered[t + lag];
|
| 1743 |
avgAcf[d][lag - 1] += s / vari;
|
| 1744 |
}
|
| 1745 |
}
|
| 1746 |
}
|
| 1747 |
|
| 1748 |
if (epCount === 0) return null;
|
| 1749 |
+
for (let d = 0; d < actionDim; d++)
|
| 1750 |
+
for (let l = 0; l < maxLag; l++) avgAcf[d][l] /= epCount;
|
| 1751 |
|
| 1752 |
const shortKeys = actionNames.map(shortName);
|
| 1753 |
const chartData = Array.from({ length: maxLag }, (_, lag) => {
|
| 1754 |
+
const row: Record<string, number> = {
|
| 1755 |
+
lag: lag + 1,
|
| 1756 |
+
time: (lag + 1) / fps,
|
| 1757 |
+
};
|
| 1758 |
+
shortKeys.forEach((k, d) => {
|
| 1759 |
+
row[k] = avgAcf[d][lag];
|
| 1760 |
+
});
|
| 1761 |
return row;
|
| 1762 |
});
|
| 1763 |
|
| 1764 |
// Suggested chunk: median lag where ACF drops below 0.5
|
| 1765 |
+
const lags = avgAcf
|
| 1766 |
+
.map((acf) => {
|
| 1767 |
+
const i = acf.findIndex((v) => v < 0.5);
|
| 1768 |
+
return i >= 0 ? i + 1 : null;
|
| 1769 |
+
})
|
| 1770 |
+
.filter(Boolean) as number[];
|
| 1771 |
+
const suggestedChunk =
|
| 1772 |
+
lags.length > 0
|
| 1773 |
+
? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)]
|
| 1774 |
+
: null;
|
| 1775 |
|
| 1776 |
return { chartData, suggestedChunk, shortKeys };
|
| 1777 |
})();
|
| 1778 |
|
| 1779 |
// Per-episode jerkiness: mean |Δa| across all dimensions
|
| 1780 |
+
const jerkyEpisodes: JerkyEpisode[] = episodeActions
|
| 1781 |
+
.map(({ index, actions: ep }) => {
|
| 1782 |
+
let sum = 0,
|
| 1783 |
+
count = 0;
|
| 1784 |
+
for (let t = 1; t < ep.length; t++) {
|
| 1785 |
+
for (let d = 0; d < actionDim; d++) {
|
| 1786 |
+
sum += Math.abs((ep[t][d] ?? 0) - (ep[t - 1][d] ?? 0));
|
| 1787 |
+
count++;
|
| 1788 |
+
}
|
| 1789 |
}
|
| 1790 |
+
return { episodeIndex: index, meanAbsDelta: count > 0 ? sum / count : 0 };
|
| 1791 |
+
})
|
| 1792 |
+
.sort((a, b) => b.meanAbsDelta - a.meanAbsDelta);
|
| 1793 |
|
| 1794 |
// Speed distribution: all episode movement scores (not just lowest 10)
|
| 1795 |
+
const speedDistribution: SpeedDistEntry[] = movementScores.map((s) => ({
|
| 1796 |
episodeIndex: s.episodeIndex,
|
| 1797 |
speed: s.totalMovement,
|
| 1798 |
}));
|
|
|
|
| 1802 |
if (!stateKey || stateDim === 0) return null;
|
| 1803 |
|
| 1804 |
let sNms: unknown = stateEntry![1].names;
|
| 1805 |
+
while (typeof sNms === "object" && sNms !== null && !Array.isArray(sNms))
|
| 1806 |
+
sNms = Object.values(sNms)[0];
|
| 1807 |
const stateNames = Array.isArray(sNms)
|
| 1808 |
? (sNms as string[])
|
| 1809 |
: Array.from({ length: stateDim }, (_, i) => `${i}`);
|
| 1810 |
+
const actionSuffixes = actionNames.map((n) => {
|
| 1811 |
+
const p = n.split(SERIES_NAME_DELIMITER);
|
| 1812 |
+
return p[p.length - 1];
|
| 1813 |
+
});
|
| 1814 |
|
| 1815 |
// Match pairs by suffix, fall back to index
|
| 1816 |
const pairs: [number, number][] = [];
|
| 1817 |
for (let ai = 0; ai < actionDim; ai++) {
|
| 1818 |
+
const si = stateNames.findIndex((s) => s === actionSuffixes[ai]);
|
| 1819 |
if (si >= 0) pairs.push([ai, si]);
|
| 1820 |
}
|
| 1821 |
if (pairs.length === 0) {
|
|
|
|
| 1838 |
|
| 1839 |
for (let pi = 0; pi < pairs.length; pi++) {
|
| 1840 |
const [ai, si] = pairs[pi];
|
| 1841 |
+
const aVals = actions.slice(0, n).map((r) => r[ai] ?? 0);
|
| 1842 |
+
const sDeltas = Array.from(
|
| 1843 |
+
{ length: n - 1 },
|
| 1844 |
+
(_, t) => (states[t + 1][si] ?? 0) - (states[t][si] ?? 0),
|
| 1845 |
+
);
|
| 1846 |
const effN = Math.min(aVals.length, sDeltas.length);
|
| 1847 |
const aM = aVals.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
|
| 1848 |
const sM = sDeltas.slice(0, effN).reduce((a, b) => a + b, 0) / effN;
|
| 1849 |
|
| 1850 |
for (let li = 0; li < numLags; li++) {
|
| 1851 |
const lag = -maxLag + li;
|
| 1852 |
+
let sum = 0,
|
| 1853 |
+
aV = 0,
|
| 1854 |
+
sV = 0;
|
| 1855 |
for (let t = 0; t < effN; t++) {
|
| 1856 |
const sIdx = t + lag;
|
| 1857 |
if (sIdx < 0 || sIdx >= sDeltas.length) continue;
|
| 1858 |
+
const a = aVals[t] - aM,
|
| 1859 |
+
s = sDeltas[sIdx] - sM;
|
| 1860 |
+
sum += a * s;
|
| 1861 |
+
aV += a * a;
|
| 1862 |
+
sV += s * s;
|
| 1863 |
}
|
| 1864 |
const d = Math.sqrt(aV * sV);
|
| 1865 |
+
if (d > 0) {
|
| 1866 |
+
corrSums[pi][li] += sum / d;
|
| 1867 |
+
corrCounts[pi][li]++;
|
| 1868 |
+
}
|
| 1869 |
}
|
| 1870 |
}
|
| 1871 |
}
|
| 1872 |
|
| 1873 |
const avgCorrs = pairs.map((_, pi) =>
|
| 1874 |
Array.from({ length: numLags }, (_, li) =>
|
| 1875 |
+
corrCounts[pi][li] > 0 ? corrSums[pi][li] / corrCounts[pi][li] : 0,
|
| 1876 |
+
),
|
| 1877 |
);
|
| 1878 |
|
| 1879 |
const ccData = Array.from({ length: numLags }, (_, li) => {
|
| 1880 |
const lag = -maxLag + li;
|
| 1881 |
+
const vals = avgCorrs.map((pc) => pc[li]);
|
| 1882 |
+
return {
|
| 1883 |
+
lag,
|
| 1884 |
+
max: Math.max(...vals),
|
| 1885 |
+
mean: vals.reduce((a, b) => a + b, 0) / vals.length,
|
| 1886 |
+
min: Math.min(...vals),
|
| 1887 |
+
};
|
| 1888 |
});
|
| 1889 |
|
| 1890 |
+
let meanPeakLag = 0,
|
| 1891 |
+
meanPeakCorr = -Infinity;
|
| 1892 |
+
let maxPeakLag = 0,
|
| 1893 |
+
maxPeakCorr = -Infinity;
|
| 1894 |
+
let minPeakLag = 0,
|
| 1895 |
+
minPeakCorr = -Infinity;
|
| 1896 |
for (const row of ccData) {
|
| 1897 |
+
if (row.max > maxPeakCorr) {
|
| 1898 |
+
maxPeakCorr = row.max;
|
| 1899 |
+
maxPeakLag = row.lag;
|
| 1900 |
+
}
|
| 1901 |
+
if (row.mean > meanPeakCorr) {
|
| 1902 |
+
meanPeakCorr = row.mean;
|
| 1903 |
+
meanPeakLag = row.lag;
|
| 1904 |
+
}
|
| 1905 |
+
if (row.min > minPeakCorr) {
|
| 1906 |
+
minPeakCorr = row.min;
|
| 1907 |
+
minPeakLag = row.lag;
|
| 1908 |
+
}
|
| 1909 |
}
|
| 1910 |
|
| 1911 |
+
const perPairPeakLags = avgCorrs.map((pc) => {
|
| 1912 |
+
let best = -Infinity,
|
| 1913 |
+
bestLag = 0;
|
| 1914 |
+
for (let li = 0; li < pc.length; li++) {
|
| 1915 |
+
if (pc[li] > best) {
|
| 1916 |
+
best = pc[li];
|
| 1917 |
+
bestLag = -maxLag + li;
|
| 1918 |
+
}
|
| 1919 |
+
}
|
| 1920 |
return bestLag;
|
| 1921 |
});
|
| 1922 |
|
| 1923 |
return {
|
| 1924 |
+
ccData,
|
| 1925 |
+
meanPeakLag,
|
| 1926 |
+
meanPeakCorr,
|
| 1927 |
+
maxPeakLag,
|
| 1928 |
+
maxPeakCorr,
|
| 1929 |
+
minPeakLag,
|
| 1930 |
+
minPeakCorr,
|
| 1931 |
+
lagRangeMin: Math.min(...perPairPeakLags),
|
| 1932 |
+
lagRangeMax: Math.max(...perPairPeakLags),
|
| 1933 |
+
numPairs: pairs.length,
|
| 1934 |
};
|
| 1935 |
})();
|
| 1936 |
|
| 1937 |
return {
|
| 1938 |
+
actionNames,
|
| 1939 |
+
timeBins,
|
| 1940 |
+
variance,
|
| 1941 |
+
numEpisodes: episodeActions.length,
|
| 1942 |
+
lowMovementEpisodes,
|
| 1943 |
+
aggVelocity,
|
| 1944 |
+
aggAutocorrelation,
|
| 1945 |
+
speedDistribution,
|
| 1946 |
+
jerkyEpisodes,
|
| 1947 |
+
aggAlignment,
|
| 1948 |
};
|
| 1949 |
}
|
| 1950 |
|
|
|
|
| 1955 |
info: DatasetMetadata,
|
| 1956 |
episodeId: number,
|
| 1957 |
): Promise<Record<string, number>[]> {
|
| 1958 |
+
const episodeMetadata = await loadEpisodeMetadataV3Simple(
|
| 1959 |
+
repoId,
|
| 1960 |
+
version,
|
| 1961 |
+
episodeId,
|
| 1962 |
+
);
|
| 1963 |
+
const { flatChartData } = await loadEpisodeDataV3(
|
| 1964 |
+
repoId,
|
| 1965 |
+
version,
|
| 1966 |
+
info,
|
| 1967 |
+
episodeMetadata,
|
| 1968 |
+
);
|
| 1969 |
return flatChartData;
|
| 1970 |
}
|
| 1971 |
|
src/app/page.tsx
CHANGED
|
@@ -6,7 +6,12 @@ import { useSearchParams } from "next/navigation";
|
|
| 6 |
|
| 7 |
declare global {
|
| 8 |
interface Window {
|
| 9 |
-
YT?: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
onYouTubeIframeAPIReady?: () => void;
|
| 11 |
}
|
| 12 |
}
|
|
@@ -87,7 +92,14 @@ function HomeInner() {
|
|
| 87 |
start: 0,
|
| 88 |
},
|
| 89 |
events: {
|
| 90 |
-
onReady: (event: {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
event.target.playVideo();
|
| 92 |
event.target.mute();
|
| 93 |
interval = setInterval(() => {
|
|
|
|
| 6 |
|
| 7 |
declare global {
|
| 8 |
interface Window {
|
| 9 |
+
YT?: {
|
| 10 |
+
Player: new (
|
| 11 |
+
id: string,
|
| 12 |
+
config: Record<string, unknown>,
|
| 13 |
+
) => { destroy?: () => void };
|
| 14 |
+
};
|
| 15 |
onYouTubeIframeAPIReady?: () => void;
|
| 16 |
}
|
| 17 |
}
|
|
|
|
| 92 |
start: 0,
|
| 93 |
},
|
| 94 |
events: {
|
| 95 |
+
onReady: (event: {
|
| 96 |
+
target: {
|
| 97 |
+
playVideo: () => void;
|
| 98 |
+
mute: () => void;
|
| 99 |
+
seekTo: (t: number) => void;
|
| 100 |
+
getCurrentTime: () => number;
|
| 101 |
+
};
|
| 102 |
+
}) => {
|
| 103 |
event.target.playVideo();
|
| 104 |
event.target.mute();
|
| 105 |
interval = setInterval(() => {
|
src/components/action-insights-panel.tsx
CHANGED
|
@@ -10,7 +10,14 @@ import {
|
|
| 10 |
ResponsiveContainer,
|
| 11 |
Tooltip,
|
| 12 |
} from "recharts";
|
| 13 |
-
import type {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
|
| 15 |
|
| 16 |
const DELIMITER = " | ";
|
|
@@ -22,8 +29,26 @@ function InfoToggle({ children }: { children: React.ReactNode }) {
|
|
| 22 |
const [open, setOpen] = useState(false);
|
| 23 |
return (
|
| 24 |
<>
|
| 25 |
-
<button
|
| 26 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
</button>
|
| 28 |
{open && <div className="mt-1">{children}</div>}
|
| 29 |
</>
|
|
@@ -35,7 +60,9 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
|
|
| 35 |
|
| 36 |
useEffect(() => {
|
| 37 |
if (!fs) return;
|
| 38 |
-
const onKey = (e: KeyboardEvent) => {
|
|
|
|
|
|
|
| 39 |
document.addEventListener("keydown", onKey);
|
| 40 |
return () => document.removeEventListener("keydown", onKey);
|
| 41 |
}, [fs]);
|
|
@@ -43,16 +70,35 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
|
|
| 43 |
return (
|
| 44 |
<div className="relative">
|
| 45 |
<button
|
| 46 |
-
onClick={() => setFs(v => !v)}
|
| 47 |
className="absolute top-3 right-3 z-10 p-1.5 rounded bg-slate-700/60 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
|
| 48 |
title={fs ? "Exit fullscreen" : "Fullscreen"}
|
| 49 |
>
|
| 50 |
-
<svg
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
{fs ? (
|
| 53 |
-
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
) : (
|
| 55 |
-
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
)}
|
| 57 |
</svg>
|
| 58 |
</button>
|
|
@@ -63,14 +109,32 @@ function FullscreenWrapper({ children }: { children: React.ReactNode }) {
|
|
| 63 |
className="fixed top-4 right-4 z-50 p-2 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors"
|
| 64 |
title="Exit fullscreen (Esc)"
|
| 65 |
>
|
| 66 |
-
<svg
|
| 67 |
-
|
| 68 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 69 |
</svg>
|
| 70 |
</button>
|
| 71 |
-
<div className="max-w-7xl mx-auto">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</div>
|
| 73 |
-
) :
|
|
|
|
|
|
|
| 74 |
</div>
|
| 75 |
);
|
| 76 |
}
|
|
@@ -79,11 +143,24 @@ function FlagBtn({ id }: { id: number }) {
|
|
| 79 |
const { has, toggle } = useFlaggedEpisodes();
|
| 80 |
const flagged = has(id);
|
| 81 |
return (
|
| 82 |
-
<button
|
| 83 |
-
|
| 84 |
-
|
| 85 |
-
|
| 86 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
</svg>
|
| 88 |
</button>
|
| 89 |
);
|
|
@@ -92,20 +169,41 @@ function FlagBtn({ id }: { id: number }) {
|
|
| 92 |
function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
|
| 93 |
const { addMany } = useFlaggedEpisodes();
|
| 94 |
return (
|
| 95 |
-
<button
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
</svg>
|
| 101 |
{label ?? "Flag all"}
|
| 102 |
</button>
|
| 103 |
);
|
| 104 |
}
|
| 105 |
const COLORS = [
|
| 106 |
-
"#f97316",
|
| 107 |
-
"#
|
| 108 |
-
"#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 109 |
];
|
| 110 |
|
| 111 |
function shortName(key: string): string {
|
|
@@ -115,13 +213,16 @@ function shortName(key: string): string {
|
|
| 115 |
|
| 116 |
function getActionKeys(row: Record<string, number>): string[] {
|
| 117 |
return Object.keys(row)
|
| 118 |
-
.filter(k => k.startsWith("action") && k !== "timestamp")
|
| 119 |
.sort();
|
| 120 |
}
|
| 121 |
|
| 122 |
function getStateKeys(row: Record<string, number>): string[] {
|
| 123 |
return Object.keys(row)
|
| 124 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
| 125 |
.sort();
|
| 126 |
}
|
| 127 |
|
|
@@ -130,7 +231,7 @@ function getStateKeys(row: Record<string, number>): string[] {
|
|
| 130 |
function computeAutocorrelation(values: number[], maxLag: number): number[] {
|
| 131 |
const n = values.length;
|
| 132 |
const mean = values.reduce((a, b) => a + b, 0) / n;
|
| 133 |
-
const centered = values.map(v => v - mean);
|
| 134 |
const variance = centered.reduce((a, v) => a + v * v, 0);
|
| 135 |
if (variance === 0) return Array(maxLag).fill(0);
|
| 136 |
|
|
@@ -144,98 +245,168 @@ function computeAutocorrelation(values: number[], maxLag: number): number[] {
|
|
| 144 |
}
|
| 145 |
|
| 146 |
function findDecorrelationLag(acf: number[], threshold = 0.5): number | null {
|
| 147 |
-
const idx = acf.findIndex(v => v < threshold);
|
| 148 |
return idx >= 0 ? idx + 1 : null;
|
| 149 |
}
|
| 150 |
|
| 151 |
-
function AutocorrelationSection({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
const isFs = useIsFullscreen();
|
| 153 |
-
const actionKeys = useMemo(
|
| 154 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
|
| 156 |
const fallback = useMemo(() => {
|
| 157 |
if (agg) return null;
|
| 158 |
-
if (actionKeys.length === 0 || maxLag < 2)
|
|
|
|
| 159 |
|
| 160 |
-
const acfs = actionKeys.map(key => {
|
| 161 |
-
const values = data.map(row => row[key] ?? 0);
|
| 162 |
return computeAutocorrelation(values, maxLag);
|
| 163 |
});
|
| 164 |
|
| 165 |
const rows = Array.from({ length: maxLag }, (_, lag) => {
|
| 166 |
-
const row: Record<string, number> = {
|
| 167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 168 |
return row;
|
| 169 |
});
|
| 170 |
|
| 171 |
-
const lags = acfs
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
}, [data, actionKeys, maxLag, fps, agg]);
|
| 176 |
|
| 177 |
-
const { chartData, suggestedChunk, shortKeys } = agg ??
|
|
|
|
| 178 |
const isAgg = !!agg;
|
| 179 |
-
const numEpisodesLabel = isAgg
|
|
|
|
|
|
|
| 180 |
|
| 181 |
const yDomain = useMemo(() => {
|
| 182 |
-
if (chartData.length === 0 || shortKeys.length === 0)
|
|
|
|
| 183 |
let min = Infinity;
|
| 184 |
-
for (const row of chartData)
|
| 185 |
-
const
|
| 186 |
-
|
| 187 |
-
|
|
|
|
| 188 |
const lo = Math.floor(Math.min(min, 0) * 10) / 10;
|
| 189 |
return [lo, 1] as [number, number];
|
| 190 |
}, [chartData, shortKeys]);
|
| 191 |
|
| 192 |
-
if (shortKeys.length === 0)
|
|
|
|
| 193 |
|
| 194 |
return (
|
| 195 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 196 |
<div>
|
| 197 |
<div className="flex items-center gap-2">
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
<InfoToggle>
|
| 200 |
<p className="text-xs text-slate-400">
|
| 201 |
-
|
| 202 |
-
|
| 203 |
-
|
| 204 |
-
|
| 205 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 210 |
</InfoToggle>
|
| 211 |
</div>
|
| 212 |
</div>
|
| 213 |
|
| 214 |
{suggestedChunk && (
|
| 215 |
<div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
|
| 216 |
-
<span className="text-orange-400 font-bold text-lg tabular-nums">
|
|
|
|
|
|
|
| 217 |
<div>
|
| 218 |
<p className="text-sm text-orange-300 font-medium">
|
| 219 |
-
Suggested chunk length: {suggestedChunk} steps (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 220 |
</p>
|
| 221 |
-
<p className="text-xs text-slate-400">Median lag where autocorrelation drops below 0.5 across action dimensions</p>
|
| 222 |
</div>
|
| 223 |
</div>
|
| 224 |
)}
|
| 225 |
|
| 226 |
<div className={isFs ? "h-[500px]" : "h-64"}>
|
| 227 |
<ResponsiveContainer width="100%" height="100%">
|
| 228 |
-
<LineChart
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
| 230 |
<XAxis
|
| 231 |
dataKey="lag"
|
| 232 |
stroke="#94a3b8"
|
| 233 |
-
label={{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
/>
|
| 235 |
<YAxis stroke="#94a3b8" domain={yDomain} />
|
| 236 |
<Tooltip
|
| 237 |
-
contentStyle={{
|
| 238 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 239 |
formatter={(v: number) => v.toFixed(3)}
|
| 240 |
/>
|
| 241 |
<Line
|
|
@@ -266,7 +437,10 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
|
|
| 266 |
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
| 267 |
{shortKeys.map((name, i) => (
|
| 268 |
<div key={name} className="flex items-center gap-1.5">
|
| 269 |
-
<span
|
|
|
|
|
|
|
|
|
|
| 270 |
<span className="text-xs text-slate-400">{name}</span>
|
| 271 |
</div>
|
| 272 |
))}
|
|
@@ -277,18 +451,33 @@ function AutocorrelationSection({ data, fps, agg, numEpisodes }: { data: Record<
|
|
| 277 |
|
| 278 |
// ─── Action Velocity ─────────────────────────────────────────────
|
| 279 |
|
| 280 |
-
function ActionVelocitySection({
|
| 281 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 282 |
|
| 283 |
const fallbackStats = useMemo(() => {
|
| 284 |
if (agg && agg.length > 0) return null;
|
| 285 |
if (actionKeys.length === 0 || data.length < 2) return [];
|
| 286 |
|
| 287 |
-
return actionKeys.map(key => {
|
| 288 |
-
const values = data.map(row => row[key] ?? 0);
|
| 289 |
const deltas = values.slice(1).map((v, i) => v - values[i]);
|
| 290 |
const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
| 291 |
-
const std = Math.sqrt(
|
|
|
|
|
|
|
| 292 |
const maxAbs = Math.max(...deltas.map(Math.abs));
|
| 293 |
const binCount = 30;
|
| 294 |
const lo = Math.min(...deltas);
|
|
@@ -296,25 +485,40 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
|
|
| 296 |
const range = hi - lo || 1;
|
| 297 |
const binW = range / binCount;
|
| 298 |
const bins: number[] = new Array(binCount).fill(0);
|
| 299 |
-
for (const d of deltas) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 300 |
return { name: shortName(key), std, maxAbs, bins, lo, hi };
|
| 301 |
});
|
| 302 |
}, [data, actionKeys, agg]);
|
| 303 |
|
| 304 |
-
const stats = useMemo(
|
|
|
|
|
|
|
|
|
|
| 305 |
const isAgg = agg && agg.length > 0;
|
| 306 |
|
| 307 |
-
const maxBinCount = useMemo(
|
| 308 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
|
| 310 |
const insight = useMemo(() => {
|
| 311 |
if (stats.length === 0) return null;
|
| 312 |
-
const smooth = stats.filter(s => s.std / maxStd < 0.4);
|
| 313 |
-
const moderate = stats.filter(
|
| 314 |
-
|
|
|
|
|
|
|
| 315 |
const isGripper = (n: string) => /grip/i.test(n);
|
| 316 |
-
const jerkyNonGripper = jerky.filter(s => !isGripper(s.name));
|
| 317 |
-
const jerkyGripper = jerky.filter(s => isGripper(s.name));
|
| 318 |
const smoothRatio = smooth.length / stats.length;
|
| 319 |
|
| 320 |
let verdict: { label: string; color: string };
|
|
@@ -322,69 +526,125 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
|
|
| 322 |
verdict = { label: "Smooth", color: "text-green-400" };
|
| 323 |
else if (jerkyNonGripper.length <= 2 && smoothRatio >= 0.3)
|
| 324 |
verdict = { label: "Moderate", color: "text-yellow-400" };
|
| 325 |
-
else
|
| 326 |
-
verdict = { label: "Jerky", color: "text-red-400" };
|
| 327 |
|
| 328 |
const lines: string[] = [];
|
| 329 |
if (smooth.length > 0)
|
| 330 |
-
lines.push(
|
|
|
|
|
|
|
| 331 |
if (moderate.length > 0)
|
| 332 |
-
lines.push(
|
|
|
|
|
|
|
| 333 |
if (jerkyNonGripper.length > 0)
|
| 334 |
-
lines.push(
|
|
|
|
|
|
|
| 335 |
if (jerkyGripper.length > 0)
|
| 336 |
-
lines.push(
|
|
|
|
|
|
|
| 337 |
|
| 338 |
let tip: string;
|
| 339 |
if (verdict.label === "Smooth")
|
| 340 |
tip = "Actions are consistent — longer action chunks should work well.";
|
| 341 |
else if (verdict.label === "Moderate")
|
| 342 |
-
tip =
|
|
|
|
| 343 |
else
|
| 344 |
-
tip =
|
|
|
|
| 345 |
|
| 346 |
return { verdict, lines, tip };
|
| 347 |
}, [stats, maxStd]);
|
| 348 |
|
| 349 |
-
if (stats.length === 0)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
return (
|
| 352 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 353 |
<div>
|
| 354 |
<div className="flex items-center gap-2">
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
<InfoToggle>
|
| 357 |
<p className="text-xs text-slate-400">
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 369 |
</InfoToggle>
|
| 370 |
</div>
|
| 371 |
</div>
|
| 372 |
|
| 373 |
{/* Per-dimension mini histograms + stats */}
|
| 374 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 375 |
{stats.map((s, si) => {
|
| 376 |
const barH = 28;
|
| 377 |
return (
|
| 378 |
-
<div
|
| 379 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
<div className="flex gap-2 text-xs text-slate-400 tabular-nums">
|
| 381 |
<span>σ={s.std.toFixed(4)}</span>
|
| 382 |
-
<span>
|
|
|
|
|
|
|
| 383 |
</div>
|
| 384 |
-
<svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
{[...s.bins].map((count, bi) => {
|
| 386 |
const h = maxBinCount > 0 ? (count / maxBinCount) * barH : 0;
|
| 387 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 388 |
})}
|
| 389 |
</svg>
|
| 390 |
<div className="h-1 w-full bg-slate-700 rounded-full overflow-hidden">
|
|
@@ -392,7 +652,12 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
|
|
| 392 |
className="h-full rounded-full"
|
| 393 |
style={{
|
| 394 |
width: `${Math.min(100, (s.std / maxStd) * 100)}%`,
|
| 395 |
-
background:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 396 |
}}
|
| 397 |
/>
|
| 398 |
</div>
|
|
@@ -404,16 +669,23 @@ function ActionVelocitySection({ data, agg, numEpisodes, jerkyEpisodes }: { data
|
|
| 404 |
{insight && (
|
| 405 |
<div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-1.5">
|
| 406 |
<p className="text-sm font-medium text-slate-200">
|
| 407 |
-
Overall:
|
|
|
|
|
|
|
|
|
|
| 408 |
</p>
|
| 409 |
<ul className="text-xs text-slate-400 space-y-0.5 list-disc list-inside">
|
| 410 |
-
{insight.lines.map((l, i) =>
|
|
|
|
|
|
|
| 411 |
</ul>
|
| 412 |
<p className="text-xs text-slate-500 pt-1">{insight.tip}</p>
|
| 413 |
</div>
|
| 414 |
)}
|
| 415 |
|
| 416 |
-
{jerkyEpisodes && jerkyEpisodes.length > 0 &&
|
|
|
|
|
|
|
| 417 |
</div>
|
| 418 |
);
|
| 419 |
}
|
|
@@ -426,12 +698,18 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
|
|
| 426 |
<div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-2">
|
| 427 |
<div className="flex items-center justify-between">
|
| 428 |
<p className="text-sm font-medium text-slate-200">
|
| 429 |
-
Most Jerky Episodes
|
|
|
|
|
|
|
|
|
|
| 430 |
</p>
|
| 431 |
<div className="flex items-center gap-3">
|
| 432 |
-
<FlagAllBtn ids={display.map(e => e.episodeIndex)} />
|
| 433 |
{episodes.length > 15 && (
|
| 434 |
-
<button
|
|
|
|
|
|
|
|
|
|
| 435 |
{showAll ? "Show top 15" : `Show all ${episodes.length}`}
|
| 436 |
</button>
|
| 437 |
)}
|
|
@@ -447,11 +725,18 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
|
|
| 447 |
</tr>
|
| 448 |
</thead>
|
| 449 |
<tbody>
|
| 450 |
-
{display.map(e => (
|
| 451 |
-
<tr
|
| 452 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 453 |
<td className="py-1 pr-3">ep {e.episodeIndex}</td>
|
| 454 |
-
<td className="py-1 text-right tabular-nums">
|
|
|
|
|
|
|
| 455 |
</tr>
|
| 456 |
))}
|
| 457 |
</tbody>
|
|
@@ -463,17 +748,36 @@ function JerkyEpisodesList({ episodes }: { episodes: JerkyEpisode[] }) {
|
|
| 463 |
|
| 464 |
// ─── Cross-Episode Variance Heatmap ──────────────────────────────
|
| 465 |
|
| 466 |
-
function VarianceHeatmap({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 467 |
const isFs = useIsFullscreen();
|
| 468 |
|
| 469 |
if (loading) {
|
| 470 |
return (
|
| 471 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 472 |
-
<h3 className="text-sm font-semibold text-slate-200 mb-2">
|
|
|
|
|
|
|
| 473 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
|
| 474 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 475 |
-
<circle
|
| 476 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 477 |
</svg>
|
| 478 |
Loading cross-episode data (sampled up to 500 episodes)…
|
| 479 |
</div>
|
|
@@ -484,8 +788,12 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 484 |
if (!data) {
|
| 485 |
return (
|
| 486 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 487 |
-
<h3 className="text-sm font-semibold text-slate-200 mb-2">
|
| 488 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 489 |
</div>
|
| 490 |
);
|
| 491 |
}
|
|
@@ -497,8 +805,14 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 497 |
|
| 498 |
const baseW = isFs ? 1000 : 560;
|
| 499 |
const baseH = isFs ? 500 : 300;
|
| 500 |
-
const cellW = Math.max(
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
const labelW = 100;
|
| 503 |
const svgW = labelW + numBins * cellW + 60;
|
| 504 |
const svgH = numDims * cellH + 40;
|
|
@@ -516,22 +830,32 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 516 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 517 |
<div>
|
| 518 |
<div className="flex items-center gap-2">
|
| 519 |
-
|
| 520 |
-
|
| 521 |
-
|
| 522 |
-
|
|
|
|
|
|
|
| 523 |
<InfoToggle>
|
| 524 |
<p className="text-xs text-slate-400">
|
| 525 |
-
|
| 526 |
-
|
| 527 |
-
|
| 528 |
-
|
| 529 |
-
|
| 530 |
-
|
| 531 |
-
|
| 532 |
-
|
| 533 |
-
|
| 534 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
</InfoToggle>
|
| 536 |
</div>
|
| 537 |
</div>
|
|
@@ -553,7 +877,7 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 553 |
>
|
| 554 |
<title>{`${shortName(actionNames[di])} @ ${(timeBins[bi] * 100).toFixed(0)}%: var=${v.toFixed(5)}`}</title>
|
| 555 |
</rect>
|
| 556 |
-
))
|
| 557 |
)}
|
| 558 |
|
| 559 |
{/* Y-axis: action names */}
|
|
@@ -572,7 +896,7 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 572 |
))}
|
| 573 |
|
| 574 |
{/* X-axis labels */}
|
| 575 |
-
{[0, 0.25, 0.5, 0.75, 1].map(frac => {
|
| 576 |
const binIdx = Math.round(frac * (numBins - 1));
|
| 577 |
return (
|
| 578 |
<text
|
|
@@ -639,28 +963,66 @@ function VarianceHeatmap({ data, loading }: { data: CrossEpisodeVarianceData | n
|
|
| 639 |
|
| 640 |
// ─── Demonstrator Speed Variance ────────────────────────────────
|
| 641 |
|
| 642 |
-
function SpeedVarianceSection({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 643 |
const isFs = useIsFullscreen();
|
| 644 |
-
const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } =
|
| 645 |
-
|
| 646 |
-
|
| 647 |
-
|
| 648 |
-
|
| 649 |
-
|
| 650 |
-
|
| 651 |
-
|
| 652 |
-
|
| 653 |
-
|
| 654 |
-
|
| 655 |
-
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
|
| 659 |
-
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
|
| 663 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 664 |
|
| 665 |
if (speeds.length < 3) return null;
|
| 666 |
|
|
@@ -673,22 +1035,28 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
|
|
| 673 |
<div className="flex items-center gap-2">
|
| 674 |
<h3 className="text-sm font-semibold text-slate-200">
|
| 675 |
Demonstrator Speed Variance
|
| 676 |
-
<span className="text-xs text-slate-500 ml-2 font-normal">
|
|
|
|
|
|
|
| 677 |
</h3>
|
| 678 |
<InfoToggle>
|
| 679 |
<p className="text-xs text-slate-400">
|
| 680 |
-
Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per
|
| 681 |
-
|
| 682 |
-
|
|
|
|
|
|
|
|
|
|
| 683 |
strongly suggests normalizing trajectory speed before training.
|
| 684 |
<br />
|
| 685 |
<span className="text-slate-500">
|
| 686 |
-
Based on "Is Diversity All You Need" (AGI-Bot, 2025)
|
|
|
|
| 687 |
fine-tuning success rate.
|
| 688 |
</span>
|
| 689 |
</p>
|
| 690 |
</InfoToggle>
|
| 691 |
-
|
| 692 |
</div>
|
| 693 |
|
| 694 |
<div className="flex gap-4">
|
|
@@ -699,17 +1067,34 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
|
|
| 699 |
const speed = lo + (i + 0.5) * binW;
|
| 700 |
const ratio = median > 0 ? speed / median : 1;
|
| 701 |
const dev = Math.abs(ratio - 1);
|
| 702 |
-
const color =
|
|
|
|
| 703 |
return (
|
| 704 |
-
<rect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 705 |
<title>{`Speed ${(lo + i * binW).toFixed(3)}–${(lo + (i + 1) * binW).toFixed(3)}: ${count} ep (${ratio.toFixed(2)}× median)`}</title>
|
| 706 |
</rect>
|
| 707 |
);
|
| 708 |
})}
|
| 709 |
-
{[0, 0.25, 0.5, 0.75, 1].map(frac => {
|
| 710 |
const idx = Math.round(frac * (bins.length - 1));
|
| 711 |
return (
|
| 712 |
-
<text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 713 |
{(lo + idx * binW).toFixed(2)}
|
| 714 |
</text>
|
| 715 |
);
|
|
@@ -717,12 +1102,29 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
|
|
| 717 |
</svg>
|
| 718 |
</div>
|
| 719 |
<div className="flex flex-col gap-2 text-xs shrink-0 min-w-[120px]">
|
| 720 |
-
<div>
|
| 721 |
-
|
| 722 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 723 |
<div>
|
| 724 |
<span className="text-slate-500">CV</span>
|
| 725 |
-
<span className={`tabular-nums ml-1 font-bold ${verdict.color}`}>
|
|
|
|
|
|
|
| 726 |
</div>
|
| 727 |
</div>
|
| 728 |
</div>
|
|
@@ -739,7 +1141,17 @@ function SpeedVarianceSection({ distribution, numEpisodes }: { distribution: Spe
|
|
| 739 |
|
| 740 |
// ─── State–Action Temporal Alignment ────────────────────────────
|
| 741 |
|
| 742 |
-
function StateActionAlignmentSection({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 743 |
const isFs = useIsFullscreen();
|
| 744 |
const result = useMemo(() => {
|
| 745 |
if (agg) return { ...agg, fromAgg: true };
|
|
@@ -753,7 +1165,9 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 753 |
// Match action↔state by suffix, fall back to index matching
|
| 754 |
const pairs: [string, string][] = [];
|
| 755 |
for (const aKey of actionKeys) {
|
| 756 |
-
const match = stateKeys.find(
|
|
|
|
|
|
|
| 757 |
if (match) pairs.push([aKey, match]);
|
| 758 |
}
|
| 759 |
if (pairs.length === 0) {
|
|
@@ -765,20 +1179,27 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 765 |
// Per-pair cross-correlation
|
| 766 |
const pairCorrs: number[][] = [];
|
| 767 |
for (const [aKey, sKey] of pairs) {
|
| 768 |
-
const aVals = data.map(row => row[aKey] ?? 0);
|
| 769 |
-
const sDeltas = data
|
|
|
|
|
|
|
| 770 |
const n = Math.min(aVals.length, sDeltas.length);
|
| 771 |
const aM = aVals.slice(0, n).reduce((a, b) => a + b, 0) / n;
|
| 772 |
const sM = sDeltas.slice(0, n).reduce((a, b) => a + b, 0) / n;
|
| 773 |
|
| 774 |
const corrs: number[] = [];
|
| 775 |
for (let lag = -maxLag; lag <= maxLag; lag++) {
|
| 776 |
-
let sum = 0,
|
|
|
|
|
|
|
| 777 |
for (let t = 0; t < n; t++) {
|
| 778 |
const sIdx = t + lag;
|
| 779 |
if (sIdx < 0 || sIdx >= sDeltas.length) continue;
|
| 780 |
-
const a = aVals[t] - aM,
|
| 781 |
-
|
|
|
|
|
|
|
|
|
|
| 782 |
}
|
| 783 |
const d = Math.sqrt(aV * sV);
|
| 784 |
corrs.push(d > 0 ? sum / d : 0);
|
|
@@ -789,9 +1210,10 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 789 |
// Aggregate min/mean/max per lag
|
| 790 |
const ccData = Array.from({ length: 2 * maxLag + 1 }, (_, li) => {
|
| 791 |
const lag = -maxLag + li;
|
| 792 |
-
const vals = pairCorrs.map(pc => pc[li]);
|
| 793 |
return {
|
| 794 |
-
lag,
|
|
|
|
| 795 |
max: Math.max(...vals),
|
| 796 |
mean: vals.reduce((a, b) => a + b, 0) / vals.length,
|
| 797 |
min: Math.min(...vals),
|
|
@@ -799,32 +1221,74 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 799 |
});
|
| 800 |
|
| 801 |
// Peaks of the envelope curves
|
| 802 |
-
let meanPeakLag = 0,
|
| 803 |
-
|
| 804 |
-
let
|
|
|
|
|
|
|
|
|
|
| 805 |
for (const row of ccData) {
|
| 806 |
-
if (row.max > maxPeakCorr) {
|
| 807 |
-
|
| 808 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
}
|
| 810 |
|
| 811 |
// Per-pair individual peak lags (for showing the true range across dimensions)
|
| 812 |
-
const perPairPeakLags = pairCorrs.map(pc => {
|
| 813 |
-
let best = -Infinity,
|
|
|
|
| 814 |
for (let li = 0; li < pc.length; li++) {
|
| 815 |
-
if (pc[li] > best) {
|
|
|
|
|
|
|
|
|
|
| 816 |
}
|
| 817 |
return bestLag;
|
| 818 |
});
|
| 819 |
const lagRangeMin = Math.min(...perPairPeakLags);
|
| 820 |
const lagRangeMax = Math.max(...perPairPeakLags);
|
| 821 |
|
| 822 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 823 |
}, [data, fps, agg]);
|
| 824 |
|
| 825 |
if (!result) return null;
|
| 826 |
-
const {
|
| 827 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 828 |
|
| 829 |
return (
|
| 830 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
|
@@ -832,20 +1296,27 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 832 |
<div className="flex items-center gap-2">
|
| 833 |
<h3 className="text-sm font-semibold text-slate-200">
|
| 834 |
State–Action Temporal Alignment
|
| 835 |
-
<span className="text-xs text-slate-500 ml-2 font-normal">
|
|
|
|
|
|
|
| 836 |
</h3>
|
| 837 |
<InfoToggle>
|
| 838 |
<p className="text-xs text-slate-400">
|
| 839 |
-
Per-dimension cross-correlation between action<sub>d</sub>(t) and
|
| 840 |
-
<
|
| 841 |
-
<span className="text-
|
| 842 |
-
|
| 843 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 844 |
<br />
|
| 845 |
<span className="text-slate-500">
|
| 846 |
-
Central to ACT (Zhao et al., 2023 — action chunking compensates
|
| 847 |
-
Real-Time Chunking (RTC, 2024), and Training-Time
|
| 848 |
-
|
|
|
|
| 849 |
</span>
|
| 850 |
</p>
|
| 851 |
</InfoToggle>
|
|
@@ -854,16 +1325,21 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 854 |
|
| 855 |
{meanPeakLag !== 0 && (
|
| 856 |
<div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
|
| 857 |
-
<span className="text-orange-400 font-bold text-lg tabular-nums">
|
|
|
|
|
|
|
| 858 |
<div>
|
| 859 |
<p className="text-sm text-orange-300 font-medium">
|
| 860 |
-
Mean control delay: {meanPeakLag} step
|
|
|
|
|
|
|
| 861 |
</p>
|
| 862 |
<p className="text-xs text-slate-400">
|
| 863 |
{meanPeakLag > 0
|
| 864 |
? `State changes lag behind actions by ~${meanPeakLag} frames on average. Consider aligning action[t] with state[t+${meanPeakLag}].`
|
| 865 |
: `Actions lag behind state changes by ~${-meanPeakLag} frames on average (predictive actions).`}
|
| 866 |
-
{lagRangeMin !== lagRangeMax &&
|
|
|
|
| 867 |
</p>
|
| 868 |
</div>
|
| 869 |
</div>
|
|
@@ -871,49 +1347,102 @@ function StateActionAlignmentSection({ data, fps, agg, numEpisodes }: { data: Re
|
|
| 871 |
|
| 872 |
<div className={isFs ? "h-[500px]" : "h-56"}>
|
| 873 |
<ResponsiveContainer width="100%" height="100%">
|
| 874 |
-
<LineChart
|
|
|
|
|
|
|
|
|
|
| 875 |
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
| 876 |
-
<XAxis
|
| 877 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 878 |
<YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
|
| 879 |
<Tooltip
|
| 880 |
-
contentStyle={{
|
| 881 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 882 |
formatter={(v: number) => v.toFixed(3)}
|
| 883 |
/>
|
| 884 |
-
<Line
|
| 885 |
-
|
| 886 |
-
|
| 887 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 888 |
</LineChart>
|
| 889 |
</ResponsiveContainer>
|
| 890 |
-
|
| 891 |
|
| 892 |
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
| 893 |
<div className="flex items-center gap-1.5">
|
| 894 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
|
| 895 |
-
<span className="text-xs text-slate-400">
|
| 896 |
-
|
|
|
|
|
|
|
| 897 |
<div className="flex items-center gap-1.5">
|
| 898 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
|
| 899 |
-
<span className="text-xs text-slate-400">
|
| 900 |
-
|
|
|
|
|
|
|
| 901 |
<div className="flex items-center gap-1.5">
|
| 902 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
|
| 903 |
-
<span className="text-xs text-slate-400">
|
|
|
|
|
|
|
| 904 |
</div>
|
| 905 |
</div>
|
| 906 |
|
| 907 |
{meanPeakLag === 0 && (
|
| 908 |
<p className="text-xs text-green-400">
|
| 909 |
-
Mean peak correlation at lag 0 (r={meanPeakCorr.toFixed(3)}) — actions
|
|
|
|
| 910 |
</p>
|
| 911 |
)}
|
| 912 |
</div>
|
| 913 |
);
|
| 914 |
}
|
| 915 |
|
| 916 |
-
|
| 917 |
// ─── Main Panel ──────────────────────────────────────────────────
|
| 918 |
|
| 919 |
interface ActionInsightsPanelProps {
|
|
@@ -935,38 +1464,74 @@ const ActionInsightsPanel: React.FC<ActionInsightsPanelProps> = ({
|
|
| 935 |
return (
|
| 936 |
<div className="max-w-5xl mx-auto py-6 space-y-8">
|
| 937 |
<div className="flex items-center justify-between flex-wrap gap-4">
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
|
|
|
| 943 |
</div>
|
| 944 |
<div className="flex items-center gap-3 shrink-0">
|
| 945 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
| 946 |
<button
|
| 947 |
-
onClick={() =>
|
|
|
|
|
|
|
| 948 |
className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${mode === "dataset" ? "bg-orange-500" : "bg-slate-600"}`}
|
| 949 |
aria-label="Toggle episode/dataset scope"
|
| 950 |
>
|
| 951 |
-
<span
|
|
|
|
|
|
|
| 952 |
</button>
|
| 953 |
-
<span
|
| 954 |
-
|
|
|
|
|
|
|
|
|
|
| 955 |
</span>
|
| 956 |
</div>
|
| 957 |
</div>
|
| 958 |
|
| 959 |
-
<FullscreenWrapper>
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
</div>
|
| 967 |
);
|
| 968 |
};
|
| 969 |
|
| 970 |
export default ActionInsightsPanel;
|
| 971 |
export { ActionVelocitySection, FullscreenWrapper };
|
| 972 |
-
|
|
|
|
| 10 |
ResponsiveContainer,
|
| 11 |
Tooltip,
|
| 12 |
} from "recharts";
|
| 13 |
+
import type {
|
| 14 |
+
CrossEpisodeVarianceData,
|
| 15 |
+
AggVelocityStat,
|
| 16 |
+
AggAutocorrelation,
|
| 17 |
+
SpeedDistEntry,
|
| 18 |
+
JerkyEpisode,
|
| 19 |
+
AggAlignment,
|
| 20 |
+
} from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 21 |
import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
|
| 22 |
|
| 23 |
const DELIMITER = " | ";
|
|
|
|
| 29 |
const [open, setOpen] = useState(false);
|
| 30 |
return (
|
| 31 |
<>
|
| 32 |
+
<button
|
| 33 |
+
onClick={() => setOpen((v) => !v)}
|
| 34 |
+
className="p-0.5 rounded-full text-slate-500 hover:text-slate-300 transition-colors shrink-0"
|
| 35 |
+
title="Toggle description"
|
| 36 |
+
>
|
| 37 |
+
<svg
|
| 38 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 39 |
+
width="14"
|
| 40 |
+
height="14"
|
| 41 |
+
viewBox="0 0 24 24"
|
| 42 |
+
fill="none"
|
| 43 |
+
stroke="currentColor"
|
| 44 |
+
strokeWidth="2"
|
| 45 |
+
strokeLinecap="round"
|
| 46 |
+
strokeLinejoin="round"
|
| 47 |
+
>
|
| 48 |
+
<circle cx="12" cy="12" r="10" />
|
| 49 |
+
<line x1="12" y1="16" x2="12" y2="12" />
|
| 50 |
+
<line x1="12" y1="8" x2="12.01" y2="8" />
|
| 51 |
+
</svg>
|
| 52 |
</button>
|
| 53 |
{open && <div className="mt-1">{children}</div>}
|
| 54 |
</>
|
|
|
|
| 60 |
|
| 61 |
useEffect(() => {
|
| 62 |
if (!fs) return;
|
| 63 |
+
const onKey = (e: KeyboardEvent) => {
|
| 64 |
+
if (e.key === "Escape") setFs(false);
|
| 65 |
+
};
|
| 66 |
document.addEventListener("keydown", onKey);
|
| 67 |
return () => document.removeEventListener("keydown", onKey);
|
| 68 |
}, [fs]);
|
|
|
|
| 70 |
return (
|
| 71 |
<div className="relative">
|
| 72 |
<button
|
| 73 |
+
onClick={() => setFs((v) => !v)}
|
| 74 |
className="absolute top-3 right-3 z-10 p-1.5 rounded bg-slate-700/60 hover:bg-slate-600 text-slate-400 hover:text-slate-200 transition-colors backdrop-blur-sm"
|
| 75 |
title={fs ? "Exit fullscreen" : "Fullscreen"}
|
| 76 |
>
|
| 77 |
+
<svg
|
| 78 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 79 |
+
width="14"
|
| 80 |
+
height="14"
|
| 81 |
+
viewBox="0 0 24 24"
|
| 82 |
+
fill="none"
|
| 83 |
+
stroke="currentColor"
|
| 84 |
+
strokeWidth="2"
|
| 85 |
+
strokeLinecap="round"
|
| 86 |
+
strokeLinejoin="round"
|
| 87 |
+
>
|
| 88 |
{fs ? (
|
| 89 |
+
<>
|
| 90 |
+
<polyline points="4 14 10 14 10 20" />
|
| 91 |
+
<polyline points="20 10 14 10 14 4" />
|
| 92 |
+
<line x1="14" y1="10" x2="21" y2="3" />
|
| 93 |
+
<line x1="3" y1="21" x2="10" y2="14" />
|
| 94 |
+
</>
|
| 95 |
) : (
|
| 96 |
+
<>
|
| 97 |
+
<polyline points="15 3 21 3 21 9" />
|
| 98 |
+
<polyline points="9 21 3 21 3 15" />
|
| 99 |
+
<line x1="21" y1="3" x2="14" y2="10" />
|
| 100 |
+
<line x1="3" y1="21" x2="10" y2="14" />
|
| 101 |
+
</>
|
| 102 |
)}
|
| 103 |
</svg>
|
| 104 |
</button>
|
|
|
|
| 109 |
className="fixed top-4 right-4 z-50 p-2 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors"
|
| 110 |
title="Exit fullscreen (Esc)"
|
| 111 |
>
|
| 112 |
+
<svg
|
| 113 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 114 |
+
width="16"
|
| 115 |
+
height="16"
|
| 116 |
+
viewBox="0 0 24 24"
|
| 117 |
+
fill="none"
|
| 118 |
+
stroke="currentColor"
|
| 119 |
+
strokeWidth="2"
|
| 120 |
+
strokeLinecap="round"
|
| 121 |
+
strokeLinejoin="round"
|
| 122 |
+
>
|
| 123 |
+
<polyline points="4 14 10 14 10 20" />
|
| 124 |
+
<polyline points="20 10 14 10 14 4" />
|
| 125 |
+
<line x1="14" y1="10" x2="21" y2="3" />
|
| 126 |
+
<line x1="3" y1="21" x2="10" y2="14" />
|
| 127 |
</svg>
|
| 128 |
</button>
|
| 129 |
+
<div className="max-w-7xl mx-auto">
|
| 130 |
+
<FullscreenCtx.Provider value={true}>
|
| 131 |
+
{children}
|
| 132 |
+
</FullscreenCtx.Provider>
|
| 133 |
+
</div>
|
| 134 |
</div>
|
| 135 |
+
) : (
|
| 136 |
+
children
|
| 137 |
+
)}
|
| 138 |
</div>
|
| 139 |
);
|
| 140 |
}
|
|
|
|
| 143 |
const { has, toggle } = useFlaggedEpisodes();
|
| 144 |
const flagged = has(id);
|
| 145 |
return (
|
| 146 |
+
<button
|
| 147 |
+
onClick={() => toggle(id)}
|
| 148 |
+
title={flagged ? "Unflag episode" : "Flag for review"}
|
| 149 |
+
className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}
|
| 150 |
+
>
|
| 151 |
+
<svg
|
| 152 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 153 |
+
width="12"
|
| 154 |
+
height="12"
|
| 155 |
+
viewBox="0 0 24 24"
|
| 156 |
+
fill={flagged ? "currentColor" : "none"}
|
| 157 |
+
stroke="currentColor"
|
| 158 |
+
strokeWidth="2"
|
| 159 |
+
strokeLinecap="round"
|
| 160 |
+
strokeLinejoin="round"
|
| 161 |
+
>
|
| 162 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 163 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 164 |
</svg>
|
| 165 |
</button>
|
| 166 |
);
|
|
|
|
| 169 |
function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
|
| 170 |
const { addMany } = useFlaggedEpisodes();
|
| 171 |
return (
|
| 172 |
+
<button
|
| 173 |
+
onClick={() => addMany(ids)}
|
| 174 |
+
className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1"
|
| 175 |
+
>
|
| 176 |
+
<svg
|
| 177 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 178 |
+
width="10"
|
| 179 |
+
height="10"
|
| 180 |
+
viewBox="0 0 24 24"
|
| 181 |
+
fill="none"
|
| 182 |
+
stroke="currentColor"
|
| 183 |
+
strokeWidth="2"
|
| 184 |
+
strokeLinecap="round"
|
| 185 |
+
strokeLinejoin="round"
|
| 186 |
+
>
|
| 187 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 188 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 189 |
</svg>
|
| 190 |
{label ?? "Flag all"}
|
| 191 |
</button>
|
| 192 |
);
|
| 193 |
}
|
| 194 |
const COLORS = [
|
| 195 |
+
"#f97316",
|
| 196 |
+
"#3b82f6",
|
| 197 |
+
"#22c55e",
|
| 198 |
+
"#ef4444",
|
| 199 |
+
"#a855f7",
|
| 200 |
+
"#eab308",
|
| 201 |
+
"#06b6d4",
|
| 202 |
+
"#ec4899",
|
| 203 |
+
"#14b8a6",
|
| 204 |
+
"#f59e0b",
|
| 205 |
+
"#6366f1",
|
| 206 |
+
"#84cc16",
|
| 207 |
];
|
| 208 |
|
| 209 |
function shortName(key: string): string {
|
|
|
|
| 213 |
|
| 214 |
function getActionKeys(row: Record<string, number>): string[] {
|
| 215 |
return Object.keys(row)
|
| 216 |
+
.filter((k) => k.startsWith("action") && k !== "timestamp")
|
| 217 |
.sort();
|
| 218 |
}
|
| 219 |
|
| 220 |
function getStateKeys(row: Record<string, number>): string[] {
|
| 221 |
return Object.keys(row)
|
| 222 |
+
.filter(
|
| 223 |
+
(k) =>
|
| 224 |
+
k.includes("state") && k !== "timestamp" && !k.startsWith("action"),
|
| 225 |
+
)
|
| 226 |
.sort();
|
| 227 |
}
|
| 228 |
|
|
|
|
| 231 |
function computeAutocorrelation(values: number[], maxLag: number): number[] {
|
| 232 |
const n = values.length;
|
| 233 |
const mean = values.reduce((a, b) => a + b, 0) / n;
|
| 234 |
+
const centered = values.map((v) => v - mean);
|
| 235 |
const variance = centered.reduce((a, v) => a + v * v, 0);
|
| 236 |
if (variance === 0) return Array(maxLag).fill(0);
|
| 237 |
|
|
|
|
| 245 |
}
|
| 246 |
|
| 247 |
function findDecorrelationLag(acf: number[], threshold = 0.5): number | null {
|
| 248 |
+
const idx = acf.findIndex((v) => v < threshold);
|
| 249 |
return idx >= 0 ? idx + 1 : null;
|
| 250 |
}
|
| 251 |
|
| 252 |
+
function AutocorrelationSection({
|
| 253 |
+
data,
|
| 254 |
+
fps,
|
| 255 |
+
agg,
|
| 256 |
+
numEpisodes,
|
| 257 |
+
}: {
|
| 258 |
+
data: Record<string, number>[];
|
| 259 |
+
fps: number;
|
| 260 |
+
agg?: AggAutocorrelation | null;
|
| 261 |
+
numEpisodes?: number;
|
| 262 |
+
}) {
|
| 263 |
const isFs = useIsFullscreen();
|
| 264 |
+
const actionKeys = useMemo(
|
| 265 |
+
() => (data.length > 0 ? getActionKeys(data[0]) : []),
|
| 266 |
+
[data],
|
| 267 |
+
);
|
| 268 |
+
const maxLag = useMemo(
|
| 269 |
+
() => Math.min(Math.floor(data.length / 2), 100),
|
| 270 |
+
[data],
|
| 271 |
+
);
|
| 272 |
|
| 273 |
const fallback = useMemo(() => {
|
| 274 |
if (agg) return null;
|
| 275 |
+
if (actionKeys.length === 0 || maxLag < 2)
|
| 276 |
+
return { chartData: [], suggestedChunk: null, shortKeys: [] as string[] };
|
| 277 |
|
| 278 |
+
const acfs = actionKeys.map((key) => {
|
| 279 |
+
const values = data.map((row) => row[key] ?? 0);
|
| 280 |
return computeAutocorrelation(values, maxLag);
|
| 281 |
});
|
| 282 |
|
| 283 |
const rows = Array.from({ length: maxLag }, (_, lag) => {
|
| 284 |
+
const row: Record<string, number> = {
|
| 285 |
+
lag: lag + 1,
|
| 286 |
+
time: (lag + 1) / fps,
|
| 287 |
+
};
|
| 288 |
+
actionKeys.forEach((key, ki) => {
|
| 289 |
+
row[shortName(key)] = acfs[ki][lag];
|
| 290 |
+
});
|
| 291 |
return row;
|
| 292 |
});
|
| 293 |
|
| 294 |
+
const lags = acfs
|
| 295 |
+
.map((acf) => findDecorrelationLag(acf, 0.5))
|
| 296 |
+
.filter(Boolean) as number[];
|
| 297 |
+
const suggested =
|
| 298 |
+
lags.length > 0
|
| 299 |
+
? lags.sort((a, b) => a - b)[Math.floor(lags.length / 2)]
|
| 300 |
+
: null;
|
| 301 |
+
|
| 302 |
+
return {
|
| 303 |
+
chartData: rows,
|
| 304 |
+
suggestedChunk: suggested,
|
| 305 |
+
shortKeys: actionKeys.map(shortName),
|
| 306 |
+
};
|
| 307 |
}, [data, actionKeys, maxLag, fps, agg]);
|
| 308 |
|
| 309 |
+
const { chartData, suggestedChunk, shortKeys } = agg ??
|
| 310 |
+
fallback ?? { chartData: [], suggestedChunk: null, shortKeys: [] };
|
| 311 |
const isAgg = !!agg;
|
| 312 |
+
const numEpisodesLabel = isAgg
|
| 313 |
+
? ` (${numEpisodes} episodes sampled)`
|
| 314 |
+
: " (current episode)";
|
| 315 |
|
| 316 |
const yDomain = useMemo(() => {
|
| 317 |
+
if (chartData.length === 0 || shortKeys.length === 0)
|
| 318 |
+
return [-0.2, 1] as [number, number];
|
| 319 |
let min = Infinity;
|
| 320 |
+
for (const row of chartData)
|
| 321 |
+
for (const k of shortKeys) {
|
| 322 |
+
const v = row[k];
|
| 323 |
+
if (typeof v === "number" && v < min) min = v;
|
| 324 |
+
}
|
| 325 |
const lo = Math.floor(Math.min(min, 0) * 10) / 10;
|
| 326 |
return [lo, 1] as [number, number];
|
| 327 |
}, [chartData, shortKeys]);
|
| 328 |
|
| 329 |
+
if (shortKeys.length === 0)
|
| 330 |
+
return <p className="text-slate-500 italic">No action columns found.</p>;
|
| 331 |
|
| 332 |
return (
|
| 333 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 334 |
<div>
|
| 335 |
<div className="flex items-center gap-2">
|
| 336 |
+
<h3 className="text-sm font-semibold text-slate-200">
|
| 337 |
+
Action Autocorrelation
|
| 338 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 339 |
+
{numEpisodesLabel}
|
| 340 |
+
</span>
|
| 341 |
+
</h3>
|
| 342 |
<InfoToggle>
|
| 343 |
<p className="text-xs text-slate-400">
|
| 344 |
+
Shows how correlated each action dimension is with itself over
|
| 345 |
+
increasing time lags. Where autocorrelation drops below 0.5
|
| 346 |
+
suggests a{" "}
|
| 347 |
+
<span className="text-orange-400 font-medium">
|
| 348 |
+
natural action chunk boundary
|
| 349 |
+
</span>{" "}
|
| 350 |
+
— actions beyond this lag are essentially independent, so
|
| 351 |
+
executing them open-loop offers diminishing returns.
|
| 352 |
+
<br />
|
| 353 |
+
<span className="text-slate-500">
|
| 354 |
+
Grounded in the theoretical result that chunk length should
|
| 355 |
+
scale logarithmically with system stability constants (Zhang et
|
| 356 |
+
al., 2025 — arXiv:2507.09061, Theorem 1).
|
| 357 |
+
</span>
|
| 358 |
+
</p>
|
| 359 |
</InfoToggle>
|
| 360 |
</div>
|
| 361 |
</div>
|
| 362 |
|
| 363 |
{suggestedChunk && (
|
| 364 |
<div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
|
| 365 |
+
<span className="text-orange-400 font-bold text-lg tabular-nums">
|
| 366 |
+
{suggestedChunk}
|
| 367 |
+
</span>
|
| 368 |
<div>
|
| 369 |
<p className="text-sm text-orange-300 font-medium">
|
| 370 |
+
Suggested chunk length: {suggestedChunk} steps (
|
| 371 |
+
{(suggestedChunk / fps).toFixed(2)}s)
|
| 372 |
+
</p>
|
| 373 |
+
<p className="text-xs text-slate-400">
|
| 374 |
+
Median lag where autocorrelation drops below 0.5 across action
|
| 375 |
+
dimensions
|
| 376 |
</p>
|
|
|
|
| 377 |
</div>
|
| 378 |
</div>
|
| 379 |
)}
|
| 380 |
|
| 381 |
<div className={isFs ? "h-[500px]" : "h-64"}>
|
| 382 |
<ResponsiveContainer width="100%" height="100%">
|
| 383 |
+
<LineChart
|
| 384 |
+
key={isAgg ? "agg" : "ep"}
|
| 385 |
+
data={chartData}
|
| 386 |
+
margin={{ top: 8, right: 16, left: 0, bottom: 16 }}
|
| 387 |
+
>
|
| 388 |
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
| 389 |
<XAxis
|
| 390 |
dataKey="lag"
|
| 391 |
stroke="#94a3b8"
|
| 392 |
+
label={{
|
| 393 |
+
value: "Lag (steps)",
|
| 394 |
+
position: "insideBottom",
|
| 395 |
+
offset: -8,
|
| 396 |
+
fill: "#94a3b8",
|
| 397 |
+
fontSize: 13,
|
| 398 |
+
}}
|
| 399 |
/>
|
| 400 |
<YAxis stroke="#94a3b8" domain={yDomain} />
|
| 401 |
<Tooltip
|
| 402 |
+
contentStyle={{
|
| 403 |
+
background: "#1e293b",
|
| 404 |
+
border: "1px solid #475569",
|
| 405 |
+
borderRadius: 6,
|
| 406 |
+
}}
|
| 407 |
+
labelFormatter={(v) =>
|
| 408 |
+
`Lag ${v} (${(Number(v) / fps).toFixed(2)}s)`
|
| 409 |
+
}
|
| 410 |
formatter={(v: number) => v.toFixed(3)}
|
| 411 |
/>
|
| 412 |
<Line
|
|
|
|
| 437 |
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
| 438 |
{shortKeys.map((name, i) => (
|
| 439 |
<div key={name} className="flex items-center gap-1.5">
|
| 440 |
+
<span
|
| 441 |
+
className="w-3 h-[3px] rounded-full shrink-0"
|
| 442 |
+
style={{ background: COLORS[i % COLORS.length] }}
|
| 443 |
+
/>
|
| 444 |
<span className="text-xs text-slate-400">{name}</span>
|
| 445 |
</div>
|
| 446 |
))}
|
|
|
|
| 451 |
|
| 452 |
// ─── Action Velocity ─────────────────────────────────────────────
|
| 453 |
|
| 454 |
+
function ActionVelocitySection({
|
| 455 |
+
data,
|
| 456 |
+
agg,
|
| 457 |
+
numEpisodes,
|
| 458 |
+
jerkyEpisodes,
|
| 459 |
+
}: {
|
| 460 |
+
data: Record<string, number>[];
|
| 461 |
+
agg?: AggVelocityStat[];
|
| 462 |
+
numEpisodes?: number;
|
| 463 |
+
jerkyEpisodes?: JerkyEpisode[];
|
| 464 |
+
}) {
|
| 465 |
+
const actionKeys = useMemo(
|
| 466 |
+
() => (data.length > 0 ? getActionKeys(data[0]) : []),
|
| 467 |
+
[data],
|
| 468 |
+
);
|
| 469 |
|
| 470 |
const fallbackStats = useMemo(() => {
|
| 471 |
if (agg && agg.length > 0) return null;
|
| 472 |
if (actionKeys.length === 0 || data.length < 2) return [];
|
| 473 |
|
| 474 |
+
return actionKeys.map((key) => {
|
| 475 |
+
const values = data.map((row) => row[key] ?? 0);
|
| 476 |
const deltas = values.slice(1).map((v, i) => v - values[i]);
|
| 477 |
const mean = deltas.reduce((a, b) => a + b, 0) / deltas.length;
|
| 478 |
+
const std = Math.sqrt(
|
| 479 |
+
deltas.reduce((a, d) => a + (d - mean) ** 2, 0) / deltas.length,
|
| 480 |
+
);
|
| 481 |
const maxAbs = Math.max(...deltas.map(Math.abs));
|
| 482 |
const binCount = 30;
|
| 483 |
const lo = Math.min(...deltas);
|
|
|
|
| 485 |
const range = hi - lo || 1;
|
| 486 |
const binW = range / binCount;
|
| 487 |
const bins: number[] = new Array(binCount).fill(0);
|
| 488 |
+
for (const d of deltas) {
|
| 489 |
+
let b = Math.floor((d - lo) / binW);
|
| 490 |
+
if (b >= binCount) b = binCount - 1;
|
| 491 |
+
bins[b]++;
|
| 492 |
+
}
|
| 493 |
return { name: shortName(key), std, maxAbs, bins, lo, hi };
|
| 494 |
});
|
| 495 |
}, [data, actionKeys, agg]);
|
| 496 |
|
| 497 |
+
const stats = useMemo(
|
| 498 |
+
() => (agg && agg.length > 0 ? agg : (fallbackStats ?? [])),
|
| 499 |
+
[agg, fallbackStats],
|
| 500 |
+
);
|
| 501 |
const isAgg = agg && agg.length > 0;
|
| 502 |
|
| 503 |
+
const maxBinCount = useMemo(
|
| 504 |
+
() => (stats.length > 0 ? Math.max(...stats.flatMap((s) => s.bins)) : 0),
|
| 505 |
+
[stats],
|
| 506 |
+
);
|
| 507 |
+
const maxStd = useMemo(
|
| 508 |
+
() => (stats.length > 0 ? Math.max(...stats.map((s) => s.std)) : 1),
|
| 509 |
+
[stats],
|
| 510 |
+
);
|
| 511 |
|
| 512 |
const insight = useMemo(() => {
|
| 513 |
if (stats.length === 0) return null;
|
| 514 |
+
const smooth = stats.filter((s) => s.std / maxStd < 0.4);
|
| 515 |
+
const moderate = stats.filter(
|
| 516 |
+
(s) => s.std / maxStd >= 0.4 && s.std / maxStd < 0.7,
|
| 517 |
+
);
|
| 518 |
+
const jerky = stats.filter((s) => s.std / maxStd >= 0.7);
|
| 519 |
const isGripper = (n: string) => /grip/i.test(n);
|
| 520 |
+
const jerkyNonGripper = jerky.filter((s) => !isGripper(s.name));
|
| 521 |
+
const jerkyGripper = jerky.filter((s) => isGripper(s.name));
|
| 522 |
const smoothRatio = smooth.length / stats.length;
|
| 523 |
|
| 524 |
let verdict: { label: string; color: string };
|
|
|
|
| 526 |
verdict = { label: "Smooth", color: "text-green-400" };
|
| 527 |
else if (jerkyNonGripper.length <= 2 && smoothRatio >= 0.3)
|
| 528 |
verdict = { label: "Moderate", color: "text-yellow-400" };
|
| 529 |
+
else verdict = { label: "Jerky", color: "text-red-400" };
|
|
|
|
| 530 |
|
| 531 |
const lines: string[] = [];
|
| 532 |
if (smooth.length > 0)
|
| 533 |
+
lines.push(
|
| 534 |
+
`${smooth.length} smooth (${smooth.map((s) => s.name).join(", ")})`,
|
| 535 |
+
);
|
| 536 |
if (moderate.length > 0)
|
| 537 |
+
lines.push(
|
| 538 |
+
`${moderate.length} moderate (${moderate.map((s) => s.name).join(", ")})`,
|
| 539 |
+
);
|
| 540 |
if (jerkyNonGripper.length > 0)
|
| 541 |
+
lines.push(
|
| 542 |
+
`${jerkyNonGripper.length} jerky (${jerkyNonGripper.map((s) => s.name).join(", ")})`,
|
| 543 |
+
);
|
| 544 |
if (jerkyGripper.length > 0)
|
| 545 |
+
lines.push(
|
| 546 |
+
`${jerkyGripper.length} gripper${jerkyGripper.length > 1 ? "s" : ""} jerky — expected for binary open/close`,
|
| 547 |
+
);
|
| 548 |
|
| 549 |
let tip: string;
|
| 550 |
if (verdict.label === "Smooth")
|
| 551 |
tip = "Actions are consistent — longer action chunks should work well.";
|
| 552 |
else if (verdict.label === "Moderate")
|
| 553 |
+
tip =
|
| 554 |
+
"Some dimensions show abrupt changes. Consider moderate chunk sizes.";
|
| 555 |
else
|
| 556 |
+
tip =
|
| 557 |
+
"Many dimensions are jerky. Use shorter action chunks and consider filtering outlier episodes.";
|
| 558 |
|
| 559 |
return { verdict, lines, tip };
|
| 560 |
}, [stats, maxStd]);
|
| 561 |
|
| 562 |
+
if (stats.length === 0)
|
| 563 |
+
return (
|
| 564 |
+
<p className="text-slate-500 italic">
|
| 565 |
+
No action data for velocity analysis.
|
| 566 |
+
</p>
|
| 567 |
+
);
|
| 568 |
|
| 569 |
return (
|
| 570 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 571 |
<div>
|
| 572 |
<div className="flex items-center gap-2">
|
| 573 |
+
<h3 className="text-sm font-semibold text-slate-200">
|
| 574 |
+
Action Velocity (Δa) — Smoothness Proxy
|
| 575 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 576 |
+
{isAgg
|
| 577 |
+
? `(${numEpisodes} episodes sampled)`
|
| 578 |
+
: "(current episode)"}
|
| 579 |
+
</span>
|
| 580 |
+
</h3>
|
| 581 |
<InfoToggle>
|
| 582 |
<p className="text-xs text-slate-400">
|
| 583 |
+
Shows the distribution of frame-to-frame action changes (Δa = a
|
| 584 |
+
<sub>t+1</sub> − a<sub>t</sub>) for each dimension. A{" "}
|
| 585 |
+
<span className="text-green-400">
|
| 586 |
+
tight distribution around zero
|
| 587 |
+
</span>{" "}
|
| 588 |
+
means smooth, predictable control — the system is likely stable
|
| 589 |
+
and benefits from longer action chunks.
|
| 590 |
+
<span className="text-red-400"> Fat tails or high std</span>{" "}
|
| 591 |
+
indicate jerky demonstrations, suggesting shorter chunks and
|
| 592 |
+
potentially beneficial noise injection.
|
| 593 |
+
<br />
|
| 594 |
+
<span className="text-slate-500">
|
| 595 |
+
Relates to the Lipschitz constant L<sub>π</sub> and smoothness C
|
| 596 |
+
<sub>π</sub> in Zhang et al. (2025), which govern compounding
|
| 597 |
+
error bounds (Assumptions 3.1, 4.1).
|
| 598 |
+
</span>
|
| 599 |
+
</p>
|
| 600 |
</InfoToggle>
|
| 601 |
</div>
|
| 602 |
</div>
|
| 603 |
|
| 604 |
{/* Per-dimension mini histograms + stats */}
|
| 605 |
+
<div
|
| 606 |
+
className="grid gap-2"
|
| 607 |
+
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(180px, 1fr))" }}
|
| 608 |
+
>
|
| 609 |
{stats.map((s, si) => {
|
| 610 |
const barH = 28;
|
| 611 |
return (
|
| 612 |
+
<div
|
| 613 |
+
key={s.name}
|
| 614 |
+
className="bg-slate-900/50 rounded-md px-2.5 py-2 space-y-1"
|
| 615 |
+
>
|
| 616 |
+
<p
|
| 617 |
+
className="text-xs font-medium text-slate-200 truncate"
|
| 618 |
+
title={s.name}
|
| 619 |
+
>
|
| 620 |
+
{s.name}
|
| 621 |
+
</p>
|
| 622 |
<div className="flex gap-2 text-xs text-slate-400 tabular-nums">
|
| 623 |
<span>σ={s.std.toFixed(4)}</span>
|
| 624 |
+
<span>
|
| 625 |
+
|Δ|<sub>max</sub>={s.maxAbs.toFixed(4)}
|
| 626 |
+
</span>
|
| 627 |
</div>
|
| 628 |
+
<svg
|
| 629 |
+
width="100%"
|
| 630 |
+
viewBox={`0 0 ${s.bins.length} ${barH}`}
|
| 631 |
+
preserveAspectRatio="none"
|
| 632 |
+
className="h-7 rounded"
|
| 633 |
+
aria-label={`Δa distribution for ${s.name}`}
|
| 634 |
+
>
|
| 635 |
{[...s.bins].map((count, bi) => {
|
| 636 |
const h = maxBinCount > 0 ? (count / maxBinCount) * barH : 0;
|
| 637 |
+
return (
|
| 638 |
+
<rect
|
| 639 |
+
key={bi}
|
| 640 |
+
x={bi}
|
| 641 |
+
y={barH - h}
|
| 642 |
+
width={0.85}
|
| 643 |
+
height={h}
|
| 644 |
+
fill={COLORS[si % COLORS.length]}
|
| 645 |
+
opacity={0.7}
|
| 646 |
+
/>
|
| 647 |
+
);
|
| 648 |
})}
|
| 649 |
</svg>
|
| 650 |
<div className="h-1 w-full bg-slate-700 rounded-full overflow-hidden">
|
|
|
|
| 652 |
className="h-full rounded-full"
|
| 653 |
style={{
|
| 654 |
width: `${Math.min(100, (s.std / maxStd) * 100)}%`,
|
| 655 |
+
background:
|
| 656 |
+
s.std / maxStd < 0.4
|
| 657 |
+
? "#22c55e"
|
| 658 |
+
: s.std / maxStd < 0.7
|
| 659 |
+
? "#eab308"
|
| 660 |
+
: "#ef4444",
|
| 661 |
}}
|
| 662 |
/>
|
| 663 |
</div>
|
|
|
|
| 669 |
{insight && (
|
| 670 |
<div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-1.5">
|
| 671 |
<p className="text-sm font-medium text-slate-200">
|
| 672 |
+
Overall:{" "}
|
| 673 |
+
<span className={insight.verdict.color}>
|
| 674 |
+
{insight.verdict.label}
|
| 675 |
+
</span>
|
| 676 |
</p>
|
| 677 |
<ul className="text-xs text-slate-400 space-y-0.5 list-disc list-inside">
|
| 678 |
+
{insight.lines.map((l, i) => (
|
| 679 |
+
<li key={i}>{l}</li>
|
| 680 |
+
))}
|
| 681 |
</ul>
|
| 682 |
<p className="text-xs text-slate-500 pt-1">{insight.tip}</p>
|
| 683 |
</div>
|
| 684 |
)}
|
| 685 |
|
| 686 |
+
{jerkyEpisodes && jerkyEpisodes.length > 0 && (
|
| 687 |
+
<JerkyEpisodesList episodes={jerkyEpisodes} />
|
| 688 |
+
)}
|
| 689 |
</div>
|
| 690 |
);
|
| 691 |
}
|
|
|
|
| 698 |
<div className="bg-slate-900/60 rounded-md px-4 py-3 border border-slate-700/60 space-y-2">
|
| 699 |
<div className="flex items-center justify-between">
|
| 700 |
<p className="text-sm font-medium text-slate-200">
|
| 701 |
+
Most Jerky Episodes{" "}
|
| 702 |
+
<span className="text-xs text-slate-500 font-normal">
|
| 703 |
+
sorted by mean |Δa|
|
| 704 |
+
</span>
|
| 705 |
</p>
|
| 706 |
<div className="flex items-center gap-3">
|
| 707 |
+
<FlagAllBtn ids={display.map((e) => e.episodeIndex)} />
|
| 708 |
{episodes.length > 15 && (
|
| 709 |
+
<button
|
| 710 |
+
onClick={() => setShowAll((v) => !v)}
|
| 711 |
+
className="text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
| 712 |
+
>
|
| 713 |
{showAll ? "Show top 15" : `Show all ${episodes.length}`}
|
| 714 |
</button>
|
| 715 |
)}
|
|
|
|
| 725 |
</tr>
|
| 726 |
</thead>
|
| 727 |
<tbody>
|
| 728 |
+
{display.map((e) => (
|
| 729 |
+
<tr
|
| 730 |
+
key={e.episodeIndex}
|
| 731 |
+
className="border-b border-slate-800/40 text-slate-300"
|
| 732 |
+
>
|
| 733 |
+
<td className="py-1">
|
| 734 |
+
<FlagBtn id={e.episodeIndex} />
|
| 735 |
+
</td>
|
| 736 |
<td className="py-1 pr-3">ep {e.episodeIndex}</td>
|
| 737 |
+
<td className="py-1 text-right tabular-nums">
|
| 738 |
+
{e.meanAbsDelta.toFixed(4)}
|
| 739 |
+
</td>
|
| 740 |
</tr>
|
| 741 |
))}
|
| 742 |
</tbody>
|
|
|
|
| 748 |
|
| 749 |
// ─── Cross-Episode Variance Heatmap ──────────────────────────────
|
| 750 |
|
| 751 |
+
function VarianceHeatmap({
|
| 752 |
+
data,
|
| 753 |
+
loading,
|
| 754 |
+
}: {
|
| 755 |
+
data: CrossEpisodeVarianceData | null;
|
| 756 |
+
loading: boolean;
|
| 757 |
+
}) {
|
| 758 |
const isFs = useIsFullscreen();
|
| 759 |
|
| 760 |
if (loading) {
|
| 761 |
return (
|
| 762 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 763 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-2">
|
| 764 |
+
Cross-Episode Action Variance
|
| 765 |
+
</h3>
|
| 766 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-8 justify-center">
|
| 767 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 768 |
+
<circle
|
| 769 |
+
className="opacity-25"
|
| 770 |
+
cx="12"
|
| 771 |
+
cy="12"
|
| 772 |
+
r="10"
|
| 773 |
+
stroke="currentColor"
|
| 774 |
+
strokeWidth="4"
|
| 775 |
+
/>
|
| 776 |
+
<path
|
| 777 |
+
className="opacity-75"
|
| 778 |
+
fill="currentColor"
|
| 779 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
| 780 |
+
/>
|
| 781 |
</svg>
|
| 782 |
Loading cross-episode data (sampled up to 500 episodes)…
|
| 783 |
</div>
|
|
|
|
| 788 |
if (!data) {
|
| 789 |
return (
|
| 790 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 791 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-2">
|
| 792 |
+
Cross-Episode Action Variance
|
| 793 |
+
</h3>
|
| 794 |
+
<p className="text-slate-500 italic text-sm">
|
| 795 |
+
Not enough episodes or no action data to compute variance.
|
| 796 |
+
</p>
|
| 797 |
</div>
|
| 798 |
);
|
| 799 |
}
|
|
|
|
| 805 |
|
| 806 |
const baseW = isFs ? 1000 : 560;
|
| 807 |
const baseH = isFs ? 500 : 300;
|
| 808 |
+
const cellW = Math.max(
|
| 809 |
+
6,
|
| 810 |
+
Math.min(isFs ? 24 : 14, Math.floor(baseW / numBins)),
|
| 811 |
+
);
|
| 812 |
+
const cellH = Math.max(
|
| 813 |
+
20,
|
| 814 |
+
Math.min(isFs ? 56 : 36, Math.floor(baseH / numDims)),
|
| 815 |
+
);
|
| 816 |
const labelW = 100;
|
| 817 |
const svgW = labelW + numBins * cellW + 60;
|
| 818 |
const svgH = numDims * cellH + 40;
|
|
|
|
| 830 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 831 |
<div>
|
| 832 |
<div className="flex items-center gap-2">
|
| 833 |
+
<h3 className="text-sm font-semibold text-slate-200">
|
| 834 |
+
Cross-Episode Action Variance
|
| 835 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 836 |
+
({numEpisodes} episodes sampled)
|
| 837 |
+
</span>
|
| 838 |
+
</h3>
|
| 839 |
<InfoToggle>
|
| 840 |
<p className="text-xs text-slate-400">
|
| 841 |
+
Shows how much each action dimension varies across episodes at
|
| 842 |
+
each point in time (normalized 0–100%).
|
| 843 |
+
<span className="text-orange-400">
|
| 844 |
+
{" "}
|
| 845 |
+
High-variance regions
|
| 846 |
+
</span>{" "}
|
| 847 |
+
indicate multi-modal or inconsistent demonstrations — generative
|
| 848 |
+
policies (diffusion, flow-matching) and action chunking help here
|
| 849 |
+
by modeling multiple modes.
|
| 850 |
+
<span className="text-blue-400"> Low-variance regions</span>{" "}
|
| 851 |
+
indicate consistent behavior across demonstrations.
|
| 852 |
+
<br />
|
| 853 |
+
<span className="text-slate-500">
|
| 854 |
+
Relates to the "coverage" discussion in Zhang et al.
|
| 855 |
+
(2025) — regions with low variance may lack the exploratory
|
| 856 |
+
coverage needed to prevent compounding errors (Section 4).
|
| 857 |
+
</span>
|
| 858 |
+
</p>
|
| 859 |
</InfoToggle>
|
| 860 |
</div>
|
| 861 |
</div>
|
|
|
|
| 877 |
>
|
| 878 |
<title>{`${shortName(actionNames[di])} @ ${(timeBins[bi] * 100).toFixed(0)}%: var=${v.toFixed(5)}`}</title>
|
| 879 |
</rect>
|
| 880 |
+
)),
|
| 881 |
)}
|
| 882 |
|
| 883 |
{/* Y-axis: action names */}
|
|
|
|
| 896 |
))}
|
| 897 |
|
| 898 |
{/* X-axis labels */}
|
| 899 |
+
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
| 900 |
const binIdx = Math.round(frac * (numBins - 1));
|
| 901 |
return (
|
| 902 |
<text
|
|
|
|
| 963 |
|
| 964 |
// ─── Demonstrator Speed Variance ────────────────────────────────
|
| 965 |
|
| 966 |
+
function SpeedVarianceSection({
|
| 967 |
+
distribution,
|
| 968 |
+
numEpisodes,
|
| 969 |
+
}: {
|
| 970 |
+
distribution: SpeedDistEntry[];
|
| 971 |
+
numEpisodes: number;
|
| 972 |
+
}) {
|
| 973 |
const isFs = useIsFullscreen();
|
| 974 |
+
const { speeds, mean, std, cv, median, bins, lo, binW, maxBin, verdict } =
|
| 975 |
+
useMemo(() => {
|
| 976 |
+
const sp = distribution.map((d) => d.speed).sort((a, b) => a - b);
|
| 977 |
+
const m = sp.reduce((a, b) => a + b, 0) / sp.length;
|
| 978 |
+
const s = Math.sqrt(sp.reduce((a, v) => a + (v - m) ** 2, 0) / sp.length);
|
| 979 |
+
const c = m > 0 ? s / m : 0;
|
| 980 |
+
const med = sp[Math.floor(sp.length / 2)];
|
| 981 |
+
|
| 982 |
+
const binCount = Math.min(30, Math.ceil(Math.sqrt(sp.length)));
|
| 983 |
+
const lo = sp[0],
|
| 984 |
+
hi = sp[sp.length - 1];
|
| 985 |
+
const bw = (hi - lo || 1) / binCount;
|
| 986 |
+
const b = new Array(binCount).fill(0);
|
| 987 |
+
for (const v of sp) {
|
| 988 |
+
let i = Math.floor((v - lo) / bw);
|
| 989 |
+
if (i >= binCount) i = binCount - 1;
|
| 990 |
+
b[i]++;
|
| 991 |
+
}
|
| 992 |
+
|
| 993 |
+
let v: { label: string; color: string; tip: string };
|
| 994 |
+
if (c < 0.2)
|
| 995 |
+
v = {
|
| 996 |
+
label: "Consistent",
|
| 997 |
+
color: "text-green-400",
|
| 998 |
+
tip: "Demonstrators execute at similar speeds — no velocity normalization needed.",
|
| 999 |
+
};
|
| 1000 |
+
else if (c < 0.4)
|
| 1001 |
+
v = {
|
| 1002 |
+
label: "Moderate variance",
|
| 1003 |
+
color: "text-yellow-400",
|
| 1004 |
+
tip: "Some speed variation across demonstrators. Consider velocity normalization for best results.",
|
| 1005 |
+
};
|
| 1006 |
+
else
|
| 1007 |
+
v = {
|
| 1008 |
+
label: "High variance",
|
| 1009 |
+
color: "text-red-400",
|
| 1010 |
+
tip: "Large speed differences between demonstrations. Velocity normalization before training is strongly recommended.",
|
| 1011 |
+
};
|
| 1012 |
+
|
| 1013 |
+
return {
|
| 1014 |
+
speeds: sp,
|
| 1015 |
+
mean: m,
|
| 1016 |
+
std: s,
|
| 1017 |
+
cv: c,
|
| 1018 |
+
median: med,
|
| 1019 |
+
bins: b,
|
| 1020 |
+
lo,
|
| 1021 |
+
binW: bw,
|
| 1022 |
+
maxBin: Math.max(...b),
|
| 1023 |
+
verdict: v,
|
| 1024 |
+
};
|
| 1025 |
+
}, [distribution]);
|
| 1026 |
|
| 1027 |
if (speeds.length < 3) return null;
|
| 1028 |
|
|
|
|
| 1035 |
<div className="flex items-center gap-2">
|
| 1036 |
<h3 className="text-sm font-semibold text-slate-200">
|
| 1037 |
Demonstrator Speed Variance
|
| 1038 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 1039 |
+
({numEpisodes} episodes)
|
| 1040 |
+
</span>
|
| 1041 |
</h3>
|
| 1042 |
<InfoToggle>
|
| 1043 |
<p className="text-xs text-slate-400">
|
| 1044 |
+
Distribution of average execution speed (mean ‖Δa<sub>t</sub>‖ per
|
| 1045 |
+
frame) across all episodes. Different human demonstrators often
|
| 1046 |
+
execute at{" "}
|
| 1047 |
+
<span className="text-orange-400">different speeds</span>,
|
| 1048 |
+
creating artificial multimodality in the action distribution that
|
| 1049 |
+
confuses the policy. A coefficient of variation (CV) above 0.3
|
| 1050 |
strongly suggests normalizing trajectory speed before training.
|
| 1051 |
<br />
|
| 1052 |
<span className="text-slate-500">
|
| 1053 |
+
Based on "Is Diversity All You Need" (AGI-Bot, 2025)
|
| 1054 |
+
which shows velocity normalization dramatically improves
|
| 1055 |
fine-tuning success rate.
|
| 1056 |
</span>
|
| 1057 |
</p>
|
| 1058 |
</InfoToggle>
|
| 1059 |
+
</div>
|
| 1060 |
</div>
|
| 1061 |
|
| 1062 |
<div className="flex gap-4">
|
|
|
|
| 1067 |
const speed = lo + (i + 0.5) * binW;
|
| 1068 |
const ratio = median > 0 ? speed / median : 1;
|
| 1069 |
const dev = Math.abs(ratio - 1);
|
| 1070 |
+
const color =
|
| 1071 |
+
dev < 0.2 ? "#22c55e" : dev < 0.5 ? "#eab308" : "#ef4444";
|
| 1072 |
return (
|
| 1073 |
+
<rect
|
| 1074 |
+
key={i}
|
| 1075 |
+
x={i * barW}
|
| 1076 |
+
y={barH - h}
|
| 1077 |
+
width={barW - 1}
|
| 1078 |
+
height={Math.max(1, h)}
|
| 1079 |
+
fill={color}
|
| 1080 |
+
opacity={0.7}
|
| 1081 |
+
rx={1}
|
| 1082 |
+
>
|
| 1083 |
<title>{`Speed ${(lo + i * binW).toFixed(3)}–${(lo + (i + 1) * binW).toFixed(3)}: ${count} ep (${ratio.toFixed(2)}× median)`}</title>
|
| 1084 |
</rect>
|
| 1085 |
);
|
| 1086 |
})}
|
| 1087 |
+
{[0, 0.25, 0.5, 0.75, 1].map((frac) => {
|
| 1088 |
const idx = Math.round(frac * (bins.length - 1));
|
| 1089 |
return (
|
| 1090 |
+
<text
|
| 1091 |
+
key={frac}
|
| 1092 |
+
x={idx * barW + barW / 2}
|
| 1093 |
+
y={barH + 14}
|
| 1094 |
+
textAnchor="middle"
|
| 1095 |
+
className="fill-slate-400"
|
| 1096 |
+
fontSize={9}
|
| 1097 |
+
>
|
| 1098 |
{(lo + idx * binW).toFixed(2)}
|
| 1099 |
</text>
|
| 1100 |
);
|
|
|
|
| 1102 |
</svg>
|
| 1103 |
</div>
|
| 1104 |
<div className="flex flex-col gap-2 text-xs shrink-0 min-w-[120px]">
|
| 1105 |
+
<div>
|
| 1106 |
+
<span className="text-slate-500">Mean</span>{" "}
|
| 1107 |
+
<span className="text-slate-200 tabular-nums ml-1">
|
| 1108 |
+
{mean.toFixed(4)}
|
| 1109 |
+
</span>
|
| 1110 |
+
</div>
|
| 1111 |
+
<div>
|
| 1112 |
+
<span className="text-slate-500">Median</span>{" "}
|
| 1113 |
+
<span className="text-slate-200 tabular-nums ml-1">
|
| 1114 |
+
{median.toFixed(4)}
|
| 1115 |
+
</span>
|
| 1116 |
+
</div>
|
| 1117 |
+
<div>
|
| 1118 |
+
<span className="text-slate-500">Std</span>{" "}
|
| 1119 |
+
<span className="text-slate-200 tabular-nums ml-1">
|
| 1120 |
+
{std.toFixed(4)}
|
| 1121 |
+
</span>
|
| 1122 |
+
</div>
|
| 1123 |
<div>
|
| 1124 |
<span className="text-slate-500">CV</span>
|
| 1125 |
+
<span className={`tabular-nums ml-1 font-bold ${verdict.color}`}>
|
| 1126 |
+
{cv.toFixed(3)}
|
| 1127 |
+
</span>
|
| 1128 |
</div>
|
| 1129 |
</div>
|
| 1130 |
</div>
|
|
|
|
| 1141 |
|
| 1142 |
// ─── State–Action Temporal Alignment ────────────────────────────
|
| 1143 |
|
| 1144 |
+
function StateActionAlignmentSection({
|
| 1145 |
+
data,
|
| 1146 |
+
fps,
|
| 1147 |
+
agg,
|
| 1148 |
+
numEpisodes,
|
| 1149 |
+
}: {
|
| 1150 |
+
data: Record<string, number>[];
|
| 1151 |
+
fps: number;
|
| 1152 |
+
agg?: AggAlignment | null;
|
| 1153 |
+
numEpisodes?: number;
|
| 1154 |
+
}) {
|
| 1155 |
const isFs = useIsFullscreen();
|
| 1156 |
const result = useMemo(() => {
|
| 1157 |
if (agg) return { ...agg, fromAgg: true };
|
|
|
|
| 1165 |
// Match action↔state by suffix, fall back to index matching
|
| 1166 |
const pairs: [string, string][] = [];
|
| 1167 |
for (const aKey of actionKeys) {
|
| 1168 |
+
const match = stateKeys.find(
|
| 1169 |
+
(sKey) => shortName(sKey) === shortName(aKey),
|
| 1170 |
+
);
|
| 1171 |
if (match) pairs.push([aKey, match]);
|
| 1172 |
}
|
| 1173 |
if (pairs.length === 0) {
|
|
|
|
| 1179 |
// Per-pair cross-correlation
|
| 1180 |
const pairCorrs: number[][] = [];
|
| 1181 |
for (const [aKey, sKey] of pairs) {
|
| 1182 |
+
const aVals = data.map((row) => row[aKey] ?? 0);
|
| 1183 |
+
const sDeltas = data
|
| 1184 |
+
.slice(1)
|
| 1185 |
+
.map((row, i) => (row[sKey] ?? 0) - (data[i][sKey] ?? 0));
|
| 1186 |
const n = Math.min(aVals.length, sDeltas.length);
|
| 1187 |
const aM = aVals.slice(0, n).reduce((a, b) => a + b, 0) / n;
|
| 1188 |
const sM = sDeltas.slice(0, n).reduce((a, b) => a + b, 0) / n;
|
| 1189 |
|
| 1190 |
const corrs: number[] = [];
|
| 1191 |
for (let lag = -maxLag; lag <= maxLag; lag++) {
|
| 1192 |
+
let sum = 0,
|
| 1193 |
+
aV = 0,
|
| 1194 |
+
sV = 0;
|
| 1195 |
for (let t = 0; t < n; t++) {
|
| 1196 |
const sIdx = t + lag;
|
| 1197 |
if (sIdx < 0 || sIdx >= sDeltas.length) continue;
|
| 1198 |
+
const a = aVals[t] - aM,
|
| 1199 |
+
s = sDeltas[sIdx] - sM;
|
| 1200 |
+
sum += a * s;
|
| 1201 |
+
aV += a * a;
|
| 1202 |
+
sV += s * s;
|
| 1203 |
}
|
| 1204 |
const d = Math.sqrt(aV * sV);
|
| 1205 |
corrs.push(d > 0 ? sum / d : 0);
|
|
|
|
| 1210 |
// Aggregate min/mean/max per lag
|
| 1211 |
const ccData = Array.from({ length: 2 * maxLag + 1 }, (_, li) => {
|
| 1212 |
const lag = -maxLag + li;
|
| 1213 |
+
const vals = pairCorrs.map((pc) => pc[li]);
|
| 1214 |
return {
|
| 1215 |
+
lag,
|
| 1216 |
+
time: lag / fps,
|
| 1217 |
max: Math.max(...vals),
|
| 1218 |
mean: vals.reduce((a, b) => a + b, 0) / vals.length,
|
| 1219 |
min: Math.min(...vals),
|
|
|
|
| 1221 |
});
|
| 1222 |
|
| 1223 |
// Peaks of the envelope curves
|
| 1224 |
+
let meanPeakLag = 0,
|
| 1225 |
+
meanPeakCorr = -Infinity;
|
| 1226 |
+
let maxPeakLag = 0,
|
| 1227 |
+
maxPeakCorr = -Infinity;
|
| 1228 |
+
let minPeakLag = 0,
|
| 1229 |
+
minPeakCorr = -Infinity;
|
| 1230 |
for (const row of ccData) {
|
| 1231 |
+
if (row.max > maxPeakCorr) {
|
| 1232 |
+
maxPeakCorr = row.max;
|
| 1233 |
+
maxPeakLag = row.lag;
|
| 1234 |
+
}
|
| 1235 |
+
if (row.mean > meanPeakCorr) {
|
| 1236 |
+
meanPeakCorr = row.mean;
|
| 1237 |
+
meanPeakLag = row.lag;
|
| 1238 |
+
}
|
| 1239 |
+
if (row.min > minPeakCorr) {
|
| 1240 |
+
minPeakCorr = row.min;
|
| 1241 |
+
minPeakLag = row.lag;
|
| 1242 |
+
}
|
| 1243 |
}
|
| 1244 |
|
| 1245 |
// Per-pair individual peak lags (for showing the true range across dimensions)
|
| 1246 |
+
const perPairPeakLags = pairCorrs.map((pc) => {
|
| 1247 |
+
let best = -Infinity,
|
| 1248 |
+
bestLag = 0;
|
| 1249 |
for (let li = 0; li < pc.length; li++) {
|
| 1250 |
+
if (pc[li] > best) {
|
| 1251 |
+
best = pc[li];
|
| 1252 |
+
bestLag = -maxLag + li;
|
| 1253 |
+
}
|
| 1254 |
}
|
| 1255 |
return bestLag;
|
| 1256 |
});
|
| 1257 |
const lagRangeMin = Math.min(...perPairPeakLags);
|
| 1258 |
const lagRangeMax = Math.max(...perPairPeakLags);
|
| 1259 |
|
| 1260 |
+
return {
|
| 1261 |
+
ccData,
|
| 1262 |
+
meanPeakLag,
|
| 1263 |
+
meanPeakCorr,
|
| 1264 |
+
maxPeakLag,
|
| 1265 |
+
maxPeakCorr,
|
| 1266 |
+
minPeakLag,
|
| 1267 |
+
minPeakCorr,
|
| 1268 |
+
lagRangeMin,
|
| 1269 |
+
lagRangeMax,
|
| 1270 |
+
numPairs: pairs.length,
|
| 1271 |
+
fromAgg: false,
|
| 1272 |
+
};
|
| 1273 |
}, [data, fps, agg]);
|
| 1274 |
|
| 1275 |
if (!result) return null;
|
| 1276 |
+
const {
|
| 1277 |
+
ccData,
|
| 1278 |
+
meanPeakLag,
|
| 1279 |
+
meanPeakCorr,
|
| 1280 |
+
maxPeakLag,
|
| 1281 |
+
maxPeakCorr,
|
| 1282 |
+
minPeakLag,
|
| 1283 |
+
minPeakCorr,
|
| 1284 |
+
lagRangeMin,
|
| 1285 |
+
lagRangeMax,
|
| 1286 |
+
numPairs,
|
| 1287 |
+
fromAgg,
|
| 1288 |
+
} = result;
|
| 1289 |
+
const scopeLabel = fromAgg
|
| 1290 |
+
? `${numEpisodes} episodes sampled`
|
| 1291 |
+
: "current episode";
|
| 1292 |
|
| 1293 |
return (
|
| 1294 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
|
|
|
| 1296 |
<div className="flex items-center gap-2">
|
| 1297 |
<h3 className="text-sm font-semibold text-slate-200">
|
| 1298 |
State–Action Temporal Alignment
|
| 1299 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 1300 |
+
({scopeLabel}, {numPairs} matched pair{numPairs !== 1 ? "s" : ""})
|
| 1301 |
+
</span>
|
| 1302 |
</h3>
|
| 1303 |
<InfoToggle>
|
| 1304 |
<p className="text-xs text-slate-400">
|
| 1305 |
+
Per-dimension cross-correlation between action<sub>d</sub>(t) and
|
| 1306 |
+
Δstate<sub>d</sub>(t+lag), aggregated as
|
| 1307 |
+
<span className="text-orange-400"> max</span>,{" "}
|
| 1308 |
+
<span className="text-slate-200">mean</span>, and
|
| 1309 |
+
<span className="text-blue-400"> min</span> across all matched
|
| 1310 |
+
action–state pairs. The{" "}
|
| 1311 |
+
<span className="text-orange-400">peak lag</span> reveals the
|
| 1312 |
+
effective control delay — the time between when an action is
|
| 1313 |
+
commanded and when the corresponding state changes.
|
| 1314 |
<br />
|
| 1315 |
<span className="text-slate-500">
|
| 1316 |
+
Central to ACT (Zhao et al., 2023 — action chunking compensates
|
| 1317 |
+
for delay), Real-Time Chunking (RTC, 2024), and Training-Time
|
| 1318 |
+
RTC (Biza et al., 2025) — all address the timing mismatch
|
| 1319 |
+
between commanded actions and observed state changes.
|
| 1320 |
</span>
|
| 1321 |
</p>
|
| 1322 |
</InfoToggle>
|
|
|
|
| 1325 |
|
| 1326 |
{meanPeakLag !== 0 && (
|
| 1327 |
<div className="flex items-center gap-3 bg-orange-500/10 border border-orange-500/30 rounded-md px-4 py-2.5">
|
| 1328 |
+
<span className="text-orange-400 font-bold text-lg tabular-nums">
|
| 1329 |
+
{meanPeakLag}
|
| 1330 |
+
</span>
|
| 1331 |
<div>
|
| 1332 |
<p className="text-sm text-orange-300 font-medium">
|
| 1333 |
+
Mean control delay: {meanPeakLag} step
|
| 1334 |
+
{Math.abs(meanPeakLag) !== 1 ? "s" : ""} (
|
| 1335 |
+
{(meanPeakLag / fps).toFixed(3)}s)
|
| 1336 |
</p>
|
| 1337 |
<p className="text-xs text-slate-400">
|
| 1338 |
{meanPeakLag > 0
|
| 1339 |
? `State changes lag behind actions by ~${meanPeakLag} frames on average. Consider aligning action[t] with state[t+${meanPeakLag}].`
|
| 1340 |
: `Actions lag behind state changes by ~${-meanPeakLag} frames on average (predictive actions).`}
|
| 1341 |
+
{lagRangeMin !== lagRangeMax &&
|
| 1342 |
+
` Individual dimension peaks range from ${lagRangeMin} to ${lagRangeMax} steps.`}
|
| 1343 |
</p>
|
| 1344 |
</div>
|
| 1345 |
</div>
|
|
|
|
| 1347 |
|
| 1348 |
<div className={isFs ? "h-[500px]" : "h-56"}>
|
| 1349 |
<ResponsiveContainer width="100%" height="100%">
|
| 1350 |
+
<LineChart
|
| 1351 |
+
data={ccData}
|
| 1352 |
+
margin={{ top: 8, right: 16, left: 0, bottom: 16 }}
|
| 1353 |
+
>
|
| 1354 |
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
|
| 1355 |
+
<XAxis
|
| 1356 |
+
dataKey="lag"
|
| 1357 |
+
stroke="#94a3b8"
|
| 1358 |
+
label={{
|
| 1359 |
+
value: "Lag (steps)",
|
| 1360 |
+
position: "insideBottom",
|
| 1361 |
+
offset: -8,
|
| 1362 |
+
fill: "#94a3b8",
|
| 1363 |
+
fontSize: 13,
|
| 1364 |
+
}}
|
| 1365 |
+
/>
|
| 1366 |
<YAxis stroke="#94a3b8" domain={[-0.5, 1]} />
|
| 1367 |
<Tooltip
|
| 1368 |
+
contentStyle={{
|
| 1369 |
+
background: "#1e293b",
|
| 1370 |
+
border: "1px solid #475569",
|
| 1371 |
+
borderRadius: 6,
|
| 1372 |
+
}}
|
| 1373 |
+
labelFormatter={(v) =>
|
| 1374 |
+
`Lag ${v} (${(Number(v) / fps).toFixed(3)}s)`
|
| 1375 |
+
}
|
| 1376 |
formatter={(v: number) => v.toFixed(3)}
|
| 1377 |
/>
|
| 1378 |
+
<Line
|
| 1379 |
+
dataKey="max"
|
| 1380 |
+
stroke="#f97316"
|
| 1381 |
+
dot={false}
|
| 1382 |
+
strokeWidth={2}
|
| 1383 |
+
isAnimationActive={false}
|
| 1384 |
+
name="max"
|
| 1385 |
+
/>
|
| 1386 |
+
<Line
|
| 1387 |
+
dataKey="mean"
|
| 1388 |
+
stroke="#94a3b8"
|
| 1389 |
+
dot={false}
|
| 1390 |
+
strokeWidth={2}
|
| 1391 |
+
isAnimationActive={false}
|
| 1392 |
+
name="mean"
|
| 1393 |
+
/>
|
| 1394 |
+
<Line
|
| 1395 |
+
dataKey="min"
|
| 1396 |
+
stroke="#3b82f6"
|
| 1397 |
+
dot={false}
|
| 1398 |
+
strokeWidth={2}
|
| 1399 |
+
isAnimationActive={false}
|
| 1400 |
+
name="min"
|
| 1401 |
+
/>
|
| 1402 |
+
<Line
|
| 1403 |
+
dataKey={() => 0}
|
| 1404 |
+
stroke="#64748b"
|
| 1405 |
+
strokeDasharray="6 4"
|
| 1406 |
+
dot={false}
|
| 1407 |
+
name="zero"
|
| 1408 |
+
legendType="none"
|
| 1409 |
+
isAnimationActive={false}
|
| 1410 |
+
/>
|
| 1411 |
</LineChart>
|
| 1412 |
</ResponsiveContainer>
|
| 1413 |
+
</div>
|
| 1414 |
|
| 1415 |
<div className="flex flex-wrap gap-x-4 gap-y-1 px-1">
|
| 1416 |
<div className="flex items-center gap-1.5">
|
| 1417 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-orange-500" />
|
| 1418 |
+
<span className="text-xs text-slate-400">
|
| 1419 |
+
max (peak: lag {maxPeakLag}, r={maxPeakCorr.toFixed(3)})
|
| 1420 |
+
</span>
|
| 1421 |
+
</div>
|
| 1422 |
<div className="flex items-center gap-1.5">
|
| 1423 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-slate-400" />
|
| 1424 |
+
<span className="text-xs text-slate-400">
|
| 1425 |
+
mean (peak: lag {meanPeakLag}, r={meanPeakCorr.toFixed(3)})
|
| 1426 |
+
</span>
|
| 1427 |
+
</div>
|
| 1428 |
<div className="flex items-center gap-1.5">
|
| 1429 |
<span className="w-3 h-[3px] rounded-full shrink-0 bg-blue-500" />
|
| 1430 |
+
<span className="text-xs text-slate-400">
|
| 1431 |
+
min (peak: lag {minPeakLag}, r={minPeakCorr.toFixed(3)})
|
| 1432 |
+
</span>
|
| 1433 |
</div>
|
| 1434 |
</div>
|
| 1435 |
|
| 1436 |
{meanPeakLag === 0 && (
|
| 1437 |
<p className="text-xs text-green-400">
|
| 1438 |
+
Mean peak correlation at lag 0 (r={meanPeakCorr.toFixed(3)}) — actions
|
| 1439 |
+
and state changes are well-aligned in this episode.
|
| 1440 |
</p>
|
| 1441 |
)}
|
| 1442 |
</div>
|
| 1443 |
);
|
| 1444 |
}
|
| 1445 |
|
|
|
|
| 1446 |
// ─── Main Panel ──────────────────────────────────────────────────
|
| 1447 |
|
| 1448 |
interface ActionInsightsPanelProps {
|
|
|
|
| 1464 |
return (
|
| 1465 |
<div className="max-w-5xl mx-auto py-6 space-y-8">
|
| 1466 |
<div className="flex items-center justify-between flex-wrap gap-4">
|
| 1467 |
+
<div>
|
| 1468 |
+
<h2 className="text-xl font-bold text-slate-100">Action Insights</h2>
|
| 1469 |
+
<p className="text-sm text-slate-400 mt-1">
|
| 1470 |
+
Data-driven analysis to guide action chunking, data quality
|
| 1471 |
+
assessment, and training configuration.
|
| 1472 |
+
</p>
|
| 1473 |
</div>
|
| 1474 |
<div className="flex items-center gap-3 shrink-0">
|
| 1475 |
+
<span
|
| 1476 |
+
className={`text-sm ${mode === "episode" ? "text-slate-100 font-medium" : "text-slate-500"}`}
|
| 1477 |
+
>
|
| 1478 |
+
Current Episode
|
| 1479 |
+
</span>
|
| 1480 |
<button
|
| 1481 |
+
onClick={() =>
|
| 1482 |
+
setMode((m) => (m === "episode" ? "dataset" : "episode"))
|
| 1483 |
+
}
|
| 1484 |
className={`relative inline-flex items-center w-9 h-5 rounded-full transition-colors shrink-0 ${mode === "dataset" ? "bg-orange-500" : "bg-slate-600"}`}
|
| 1485 |
aria-label="Toggle episode/dataset scope"
|
| 1486 |
>
|
| 1487 |
+
<span
|
| 1488 |
+
className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${mode === "dataset" ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
| 1489 |
+
/>
|
| 1490 |
</button>
|
| 1491 |
+
<span
|
| 1492 |
+
className={`text-sm ${mode === "dataset" ? "text-slate-100 font-medium" : "text-slate-500"}`}
|
| 1493 |
+
>
|
| 1494 |
+
All Episodes
|
| 1495 |
+
{crossEpisodeData ? ` (${crossEpisodeData.numEpisodes})` : ""}
|
| 1496 |
</span>
|
| 1497 |
</div>
|
| 1498 |
</div>
|
| 1499 |
|
| 1500 |
+
<FullscreenWrapper>
|
| 1501 |
+
<AutocorrelationSection
|
| 1502 |
+
data={flatChartData}
|
| 1503 |
+
fps={fps}
|
| 1504 |
+
agg={showAgg ? crossEpisodeData?.aggAutocorrelation : null}
|
| 1505 |
+
numEpisodes={crossEpisodeData?.numEpisodes}
|
| 1506 |
+
/>
|
| 1507 |
+
</FullscreenWrapper>
|
| 1508 |
+
<FullscreenWrapper>
|
| 1509 |
+
<StateActionAlignmentSection
|
| 1510 |
+
data={flatChartData}
|
| 1511 |
+
fps={fps}
|
| 1512 |
+
agg={showAgg ? crossEpisodeData?.aggAlignment : null}
|
| 1513 |
+
numEpisodes={crossEpisodeData?.numEpisodes}
|
| 1514 |
+
/>
|
| 1515 |
+
</FullscreenWrapper>
|
| 1516 |
+
|
| 1517 |
+
{crossEpisodeData?.speedDistribution &&
|
| 1518 |
+
crossEpisodeData.speedDistribution.length > 2 && (
|
| 1519 |
+
<FullscreenWrapper>
|
| 1520 |
+
<SpeedVarianceSection
|
| 1521 |
+
distribution={crossEpisodeData.speedDistribution}
|
| 1522 |
+
numEpisodes={crossEpisodeData.numEpisodes}
|
| 1523 |
+
/>
|
| 1524 |
+
</FullscreenWrapper>
|
| 1525 |
+
)}
|
| 1526 |
+
<FullscreenWrapper>
|
| 1527 |
+
<VarianceHeatmap
|
| 1528 |
+
data={crossEpisodeData}
|
| 1529 |
+
loading={crossEpisodeLoading}
|
| 1530 |
+
/>
|
| 1531 |
+
</FullscreenWrapper>
|
| 1532 |
</div>
|
| 1533 |
);
|
| 1534 |
};
|
| 1535 |
|
| 1536 |
export default ActionInsightsPanel;
|
| 1537 |
export { ActionVelocitySection, FullscreenWrapper };
|
|
|
src/components/data-recharts.tsx
CHANGED
|
@@ -25,14 +25,23 @@ import React, { useMemo } from "react";
|
|
| 25 |
const SERIES_NAME_DELIMITER = " | ";
|
| 26 |
|
| 27 |
const CHART_COLORS = [
|
| 28 |
-
"#f97316",
|
| 29 |
-
"#
|
| 30 |
-
"#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
];
|
| 32 |
|
| 33 |
function mergeGroups(data: ChartRow[][]): ChartRow[] {
|
| 34 |
if (data.length <= 1) return data[0] ?? [];
|
| 35 |
-
const maxLen = Math.max(...data.map(g => g.length));
|
| 36 |
const merged: ChartRow[] = [];
|
| 37 |
for (let i = 0; i < maxLen; i++) {
|
| 38 |
const row: ChartRow = {};
|
|
@@ -40,7 +49,10 @@ function mergeGroups(data: ChartRow[][]): ChartRow[] {
|
|
| 40 |
const src = group[i];
|
| 41 |
if (!src) continue;
|
| 42 |
for (const [k, v] of Object.entries(src)) {
|
| 43 |
-
if (k === "timestamp") {
|
|
|
|
|
|
|
|
|
|
| 44 |
row[k] = v;
|
| 45 |
}
|
| 46 |
}
|
|
@@ -58,7 +70,10 @@ export const DataRecharts = React.memo(
|
|
| 58 |
if (typeof onChartsReady === "function") onChartsReady();
|
| 59 |
}, [onChartsReady]);
|
| 60 |
|
| 61 |
-
const combinedData = useMemo(
|
|
|
|
|
|
|
|
|
|
| 62 |
|
| 63 |
if (!Array.isArray(data) || data.length === 0) return null;
|
| 64 |
|
|
@@ -67,19 +82,38 @@ export const DataRecharts = React.memo(
|
|
| 67 |
{data.length > 1 && (
|
| 68 |
<div className="flex justify-end mb-2">
|
| 69 |
<button
|
| 70 |
-
onClick={() => setExpanded(v => !v)}
|
| 71 |
className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
|
| 72 |
expanded
|
| 73 |
? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
|
| 74 |
: "bg-slate-800/60 text-slate-400 hover:text-slate-200 border border-slate-700/50"
|
| 75 |
}`}
|
| 76 |
>
|
| 77 |
-
<svg
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
{expanded ? (
|
| 80 |
-
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 81 |
) : (
|
| 82 |
-
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
)}
|
| 84 |
</svg>
|
| 85 |
{expanded ? "Split charts" : "Combine all"}
|
|
@@ -88,11 +122,21 @@ export const DataRecharts = React.memo(
|
|
| 88 |
)}
|
| 89 |
|
| 90 |
{expanded ? (
|
| 91 |
-
<SingleDataGraph
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
) : (
|
| 93 |
<div className="grid md:grid-cols-2 grid-cols-1 gap-4">
|
| 94 |
{data.map((group, idx) => (
|
| 95 |
-
<SingleDataGraph
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
))}
|
| 97 |
</div>
|
| 98 |
)}
|
|
@@ -114,7 +158,10 @@ const SingleDataGraph = React.memo(
|
|
| 114 |
tall?: boolean;
|
| 115 |
}) => {
|
| 116 |
const { currentTime, setCurrentTime } = useTime();
|
| 117 |
-
function flattenRow(
|
|
|
|
|
|
|
|
|
|
| 118 |
const result: Record<string, number> = {};
|
| 119 |
for (const [key, value] of Object.entries(row)) {
|
| 120 |
// Special case: if this is a group value that is a primitive, assign to prefix.key
|
|
@@ -192,7 +239,9 @@ const SingleDataGraph = React.memo(
|
|
| 192 |
setHoveredTime(null);
|
| 193 |
};
|
| 194 |
|
| 195 |
-
const handleClick = (
|
|
|
|
|
|
|
| 196 |
if (data?.activePayload?.length) {
|
| 197 |
setCurrentTime(data.activePayload[0].payload.timestamp);
|
| 198 |
}
|
|
@@ -268,13 +317,18 @@ const SingleDataGraph = React.memo(
|
|
| 268 |
className="size-3"
|
| 269 |
style={{ accentColor: color }}
|
| 270 |
/>
|
| 271 |
-
<span className="text-xs font-semibold text-slate-200">
|
|
|
|
|
|
|
| 272 |
</label>
|
| 273 |
<div className="pl-5 flex flex-col gap-0.5 mt-0.5">
|
| 274 |
{children.map((key) => {
|
| 275 |
const label = key.split(SERIES_NAME_DELIMITER).pop() ?? key;
|
| 276 |
return (
|
| 277 |
-
<label
|
|
|
|
|
|
|
|
|
|
| 278 |
<input
|
| 279 |
type="checkbox"
|
| 280 |
checked={visibleKeys.includes(key)}
|
|
@@ -282,9 +336,17 @@ const SingleDataGraph = React.memo(
|
|
| 282 |
className="size-2.5"
|
| 283 |
style={{ accentColor: color }}
|
| 284 |
/>
|
| 285 |
-
<span
|
| 286 |
-
|
| 287 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 288 |
</span>
|
| 289 |
</label>
|
| 290 |
);
|
|
@@ -296,7 +358,10 @@ const SingleDataGraph = React.memo(
|
|
| 296 |
{singles.map((key) => {
|
| 297 |
const color = groupColorMap[key];
|
| 298 |
return (
|
| 299 |
-
<label
|
|
|
|
|
|
|
|
|
|
| 300 |
<input
|
| 301 |
type="checkbox"
|
| 302 |
checked={visibleKeys.includes(key)}
|
|
@@ -304,9 +369,17 @@ const SingleDataGraph = React.memo(
|
|
| 304 |
className="size-3"
|
| 305 |
style={{ accentColor: color }}
|
| 306 |
/>
|
| 307 |
-
<span
|
| 308 |
-
|
| 309 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
</span>
|
| 311 |
</label>
|
| 312 |
);
|
|
@@ -319,7 +392,7 @@ const SingleDataGraph = React.memo(
|
|
| 319 |
const chartTitle = useMemo(() => {
|
| 320 |
const featureNames = Object.keys(groups);
|
| 321 |
if (featureNames.length > 0) {
|
| 322 |
-
const suffixes = featureNames.map(g => {
|
| 323 |
const parts = g.split(SERIES_NAME_DELIMITER);
|
| 324 |
return parts[parts.length - 1];
|
| 325 |
});
|
|
@@ -331,9 +404,17 @@ const SingleDataGraph = React.memo(
|
|
| 331 |
return (
|
| 332 |
<div className="w-full bg-slate-800/40 rounded-lg border border-slate-700/50 p-3">
|
| 333 |
{chartTitle && (
|
| 334 |
-
<p
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 335 |
)}
|
| 336 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 337 |
<ResponsiveContainer width="100%" height="100%">
|
| 338 |
<LineChart
|
| 339 |
data={chartData}
|
|
@@ -341,12 +422,18 @@ const SingleDataGraph = React.memo(
|
|
| 341 |
margin={{ top: 12, right: 12, left: -8, bottom: 8 }}
|
| 342 |
onClick={handleClick}
|
| 343 |
onMouseMove={(state) => {
|
| 344 |
-
const payload = state?.activePayload?.[0]?.payload as
|
|
|
|
|
|
|
| 345 |
setHoveredTime(payload?.timestamp ?? null);
|
| 346 |
}}
|
| 347 |
onMouseLeave={handleMouseLeave}
|
| 348 |
>
|
| 349 |
-
<CartesianGrid
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
<XAxis
|
| 351 |
dataKey="timestamp"
|
| 352 |
domain={[
|
|
@@ -384,7 +471,9 @@ const SingleDataGraph = React.memo(
|
|
| 384 |
/>
|
| 385 |
|
| 386 |
{dataKeys.map((key) => {
|
| 387 |
-
const group = key.includes(SERIES_NAME_DELIMITER)
|
|
|
|
|
|
|
| 388 |
const color = groupColorMap[group];
|
| 389 |
let strokeDasharray: string | undefined = undefined;
|
| 390 |
if (groups[group] && groups[group].length > 1) {
|
|
|
|
| 25 |
const SERIES_NAME_DELIMITER = " | ";
|
| 26 |
|
| 27 |
const CHART_COLORS = [
|
| 28 |
+
"#f97316",
|
| 29 |
+
"#3b82f6",
|
| 30 |
+
"#22c55e",
|
| 31 |
+
"#ef4444",
|
| 32 |
+
"#a855f7",
|
| 33 |
+
"#eab308",
|
| 34 |
+
"#06b6d4",
|
| 35 |
+
"#ec4899",
|
| 36 |
+
"#14b8a6",
|
| 37 |
+
"#f59e0b",
|
| 38 |
+
"#6366f1",
|
| 39 |
+
"#84cc16",
|
| 40 |
];
|
| 41 |
|
| 42 |
function mergeGroups(data: ChartRow[][]): ChartRow[] {
|
| 43 |
if (data.length <= 1) return data[0] ?? [];
|
| 44 |
+
const maxLen = Math.max(...data.map((g) => g.length));
|
| 45 |
const merged: ChartRow[] = [];
|
| 46 |
for (let i = 0; i < maxLen; i++) {
|
| 47 |
const row: ChartRow = {};
|
|
|
|
| 49 |
const src = group[i];
|
| 50 |
if (!src) continue;
|
| 51 |
for (const [k, v] of Object.entries(src)) {
|
| 52 |
+
if (k === "timestamp") {
|
| 53 |
+
row[k] = v;
|
| 54 |
+
continue;
|
| 55 |
+
}
|
| 56 |
row[k] = v;
|
| 57 |
}
|
| 58 |
}
|
|
|
|
| 70 |
if (typeof onChartsReady === "function") onChartsReady();
|
| 71 |
}, [onChartsReady]);
|
| 72 |
|
| 73 |
+
const combinedData = useMemo(
|
| 74 |
+
() => (expanded ? mergeGroups(data) : []),
|
| 75 |
+
[data, expanded],
|
| 76 |
+
);
|
| 77 |
|
| 78 |
if (!Array.isArray(data) || data.length === 0) return null;
|
| 79 |
|
|
|
|
| 82 |
{data.length > 1 && (
|
| 83 |
<div className="flex justify-end mb-2">
|
| 84 |
<button
|
| 85 |
+
onClick={() => setExpanded((v) => !v)}
|
| 86 |
className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
|
| 87 |
expanded
|
| 88 |
? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
|
| 89 |
: "bg-slate-800/60 text-slate-400 hover:text-slate-200 border border-slate-700/50"
|
| 90 |
}`}
|
| 91 |
>
|
| 92 |
+
<svg
|
| 93 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 94 |
+
width="12"
|
| 95 |
+
height="12"
|
| 96 |
+
viewBox="0 0 24 24"
|
| 97 |
+
fill="none"
|
| 98 |
+
stroke="currentColor"
|
| 99 |
+
strokeWidth="2"
|
| 100 |
+
strokeLinecap="round"
|
| 101 |
+
strokeLinejoin="round"
|
| 102 |
+
>
|
| 103 |
{expanded ? (
|
| 104 |
+
<>
|
| 105 |
+
<polyline points="4 14 10 14 10 20" />
|
| 106 |
+
<polyline points="20 10 14 10 14 4" />
|
| 107 |
+
<line x1="14" y1="10" x2="21" y2="3" />
|
| 108 |
+
<line x1="3" y1="21" x2="10" y2="14" />
|
| 109 |
+
</>
|
| 110 |
) : (
|
| 111 |
+
<>
|
| 112 |
+
<polyline points="15 3 21 3 21 9" />
|
| 113 |
+
<polyline points="9 21 3 21 3 15" />
|
| 114 |
+
<line x1="21" y1="3" x2="14" y2="10" />
|
| 115 |
+
<line x1="3" y1="21" x2="10" y2="14" />
|
| 116 |
+
</>
|
| 117 |
)}
|
| 118 |
</svg>
|
| 119 |
{expanded ? "Split charts" : "Combine all"}
|
|
|
|
| 122 |
)}
|
| 123 |
|
| 124 |
{expanded ? (
|
| 125 |
+
<SingleDataGraph
|
| 126 |
+
data={combinedData}
|
| 127 |
+
hoveredTime={hoveredTime}
|
| 128 |
+
setHoveredTime={setHoveredTime}
|
| 129 |
+
tall
|
| 130 |
+
/>
|
| 131 |
) : (
|
| 132 |
<div className="grid md:grid-cols-2 grid-cols-1 gap-4">
|
| 133 |
{data.map((group, idx) => (
|
| 134 |
+
<SingleDataGraph
|
| 135 |
+
key={idx}
|
| 136 |
+
data={group}
|
| 137 |
+
hoveredTime={hoveredTime}
|
| 138 |
+
setHoveredTime={setHoveredTime}
|
| 139 |
+
/>
|
| 140 |
))}
|
| 141 |
</div>
|
| 142 |
)}
|
|
|
|
| 158 |
tall?: boolean;
|
| 159 |
}) => {
|
| 160 |
const { currentTime, setCurrentTime } = useTime();
|
| 161 |
+
function flattenRow(
|
| 162 |
+
row: Record<string, number | Record<string, number>>,
|
| 163 |
+
prefix = "",
|
| 164 |
+
): Record<string, number> {
|
| 165 |
const result: Record<string, number> = {};
|
| 166 |
for (const [key, value] of Object.entries(row)) {
|
| 167 |
// Special case: if this is a group value that is a primitive, assign to prefix.key
|
|
|
|
| 239 |
setHoveredTime(null);
|
| 240 |
};
|
| 241 |
|
| 242 |
+
const handleClick = (
|
| 243 |
+
data: { activePayload?: { payload: { timestamp: number } }[] } | null,
|
| 244 |
+
) => {
|
| 245 |
if (data?.activePayload?.length) {
|
| 246 |
setCurrentTime(data.activePayload[0].payload.timestamp);
|
| 247 |
}
|
|
|
|
| 317 |
className="size-3"
|
| 318 |
style={{ accentColor: color }}
|
| 319 |
/>
|
| 320 |
+
<span className="text-xs font-semibold text-slate-200">
|
| 321 |
+
{group}
|
| 322 |
+
</span>
|
| 323 |
</label>
|
| 324 |
<div className="pl-5 flex flex-col gap-0.5 mt-0.5">
|
| 325 |
{children.map((key) => {
|
| 326 |
const label = key.split(SERIES_NAME_DELIMITER).pop() ?? key;
|
| 327 |
return (
|
| 328 |
+
<label
|
| 329 |
+
key={key}
|
| 330 |
+
className="flex items-center gap-1.5 cursor-pointer select-none"
|
| 331 |
+
>
|
| 332 |
<input
|
| 333 |
type="checkbox"
|
| 334 |
checked={visibleKeys.includes(key)}
|
|
|
|
| 336 |
className="size-2.5"
|
| 337 |
style={{ accentColor: color }}
|
| 338 |
/>
|
| 339 |
+
<span
|
| 340 |
+
className={`text-xs ${visibleKeys.includes(key) ? "text-slate-300" : "text-slate-500"}`}
|
| 341 |
+
>
|
| 342 |
+
{label}
|
| 343 |
+
</span>
|
| 344 |
+
<span
|
| 345 |
+
className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}
|
| 346 |
+
>
|
| 347 |
+
{typeof currentData[key] === "number"
|
| 348 |
+
? currentData[key].toFixed(2)
|
| 349 |
+
: "–"}
|
| 350 |
</span>
|
| 351 |
</label>
|
| 352 |
);
|
|
|
|
| 358 |
{singles.map((key) => {
|
| 359 |
const color = groupColorMap[key];
|
| 360 |
return (
|
| 361 |
+
<label
|
| 362 |
+
key={key}
|
| 363 |
+
className="flex items-center gap-1.5 cursor-pointer select-none"
|
| 364 |
+
>
|
| 365 |
<input
|
| 366 |
type="checkbox"
|
| 367 |
checked={visibleKeys.includes(key)}
|
|
|
|
| 369 |
className="size-3"
|
| 370 |
style={{ accentColor: color }}
|
| 371 |
/>
|
| 372 |
+
<span
|
| 373 |
+
className={`text-xs ${visibleKeys.includes(key) ? "text-slate-200" : "text-slate-500"}`}
|
| 374 |
+
>
|
| 375 |
+
{key}
|
| 376 |
+
</span>
|
| 377 |
+
<span
|
| 378 |
+
className={`text-xs font-mono tabular-nums ml-1 ${visibleKeys.includes(key) ? "text-orange-300/80" : "text-slate-600"}`}
|
| 379 |
+
>
|
| 380 |
+
{typeof currentData[key] === "number"
|
| 381 |
+
? currentData[key].toFixed(2)
|
| 382 |
+
: "–"}
|
| 383 |
</span>
|
| 384 |
</label>
|
| 385 |
);
|
|
|
|
| 392 |
const chartTitle = useMemo(() => {
|
| 393 |
const featureNames = Object.keys(groups);
|
| 394 |
if (featureNames.length > 0) {
|
| 395 |
+
const suffixes = featureNames.map((g) => {
|
| 396 |
const parts = g.split(SERIES_NAME_DELIMITER);
|
| 397 |
return parts[parts.length - 1];
|
| 398 |
});
|
|
|
|
| 404 |
return (
|
| 405 |
<div className="w-full bg-slate-800/40 rounded-lg border border-slate-700/50 p-3">
|
| 406 |
{chartTitle && (
|
| 407 |
+
<p
|
| 408 |
+
className="text-xs font-medium text-slate-300 mb-1 px-1 truncate"
|
| 409 |
+
title={chartTitle}
|
| 410 |
+
>
|
| 411 |
+
{chartTitle}
|
| 412 |
+
</p>
|
| 413 |
)}
|
| 414 |
+
<div
|
| 415 |
+
className={`w-full ${tall ? "h-[500px]" : "h-72"}`}
|
| 416 |
+
onMouseLeave={handleMouseLeave}
|
| 417 |
+
>
|
| 418 |
<ResponsiveContainer width="100%" height="100%">
|
| 419 |
<LineChart
|
| 420 |
data={chartData}
|
|
|
|
| 422 |
margin={{ top: 12, right: 12, left: -8, bottom: 8 }}
|
| 423 |
onClick={handleClick}
|
| 424 |
onMouseMove={(state) => {
|
| 425 |
+
const payload = state?.activePayload?.[0]?.payload as
|
| 426 |
+
| { timestamp?: number }
|
| 427 |
+
| undefined;
|
| 428 |
setHoveredTime(payload?.timestamp ?? null);
|
| 429 |
}}
|
| 430 |
onMouseLeave={handleMouseLeave}
|
| 431 |
>
|
| 432 |
+
<CartesianGrid
|
| 433 |
+
strokeDasharray="3 3"
|
| 434 |
+
stroke="#334155"
|
| 435 |
+
strokeOpacity={0.6}
|
| 436 |
+
/>
|
| 437 |
<XAxis
|
| 438 |
dataKey="timestamp"
|
| 439 |
domain={[
|
|
|
|
| 471 |
/>
|
| 472 |
|
| 473 |
{dataKeys.map((key) => {
|
| 474 |
+
const group = key.includes(SERIES_NAME_DELIMITER)
|
| 475 |
+
? key.split(SERIES_NAME_DELIMITER)[0]
|
| 476 |
+
: key;
|
| 477 |
const color = groupColorMap[group];
|
| 478 |
let strokeDasharray: string | undefined = undefined;
|
| 479 |
if (groups[group] && groups[group].length > 1) {
|
src/components/filtering-panel.tsx
CHANGED
|
@@ -8,7 +8,10 @@ import type {
|
|
| 8 |
EpisodeLengthStats,
|
| 9 |
EpisodeLengthInfo,
|
| 10 |
} from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 11 |
-
import {
|
|
|
|
|
|
|
|
|
|
| 12 |
|
| 13 |
// ─── Shared small components ─────────────────────────────────────
|
| 14 |
|
|
@@ -16,11 +19,24 @@ function FlagBtn({ id }: { id: number }) {
|
|
| 16 |
const { has, toggle } = useFlaggedEpisodes();
|
| 17 |
const flagged = has(id);
|
| 18 |
return (
|
| 19 |
-
<button
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
</svg>
|
| 25 |
</button>
|
| 26 |
);
|
|
@@ -29,11 +45,23 @@ function FlagBtn({ id }: { id: number }) {
|
|
| 29 |
function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
|
| 30 |
const { addMany } = useFlaggedEpisodes();
|
| 31 |
return (
|
| 32 |
-
<button
|
| 33 |
-
|
| 34 |
-
|
| 35 |
-
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
</svg>
|
| 38 |
{label ?? "Flag all"}
|
| 39 |
</button>
|
|
@@ -44,32 +72,53 @@ function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
|
|
| 44 |
|
| 45 |
function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
|
| 46 |
if (episodes.length === 0) return null;
|
| 47 |
-
const maxMovement = Math.max(...episodes.map(e => e.totalMovement), 1e-10);
|
| 48 |
|
| 49 |
return (
|
| 50 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
|
| 51 |
<div className="flex items-center justify-between">
|
| 52 |
-
<h3 className="text-sm font-semibold text-slate-200">
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
<p className="text-xs text-slate-400">
|
| 56 |
-
Episodes with the lowest average action change per frame. Very low
|
|
|
|
|
|
|
| 57 |
</p>
|
| 58 |
-
<div
|
| 59 |
-
|
| 60 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 61 |
<FlagBtn id={ep.episodeIndex} />
|
| 62 |
-
<span className="text-xs text-slate-300 font-medium shrink-0">
|
|
|
|
|
|
|
| 63 |
<div className="flex-1 min-w-0">
|
| 64 |
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
| 65 |
-
<div
|
|
|
|
| 66 |
style={{
|
| 67 |
width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
|
| 68 |
-
background:
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
</div>
|
| 71 |
</div>
|
| 72 |
-
<span className="text-xs text-slate-500 tabular-nums shrink-0">
|
|
|
|
|
|
|
| 73 |
</div>
|
| 74 |
))}
|
| 75 |
</div>
|
|
@@ -81,23 +130,37 @@ function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
|
|
| 81 |
|
| 82 |
function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
|
| 83 |
const { addMany } = useFlaggedEpisodes();
|
| 84 |
-
const globalMin = useMemo(
|
| 85 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
|
| 87 |
const [rangeMin, setRangeMin] = useState(globalMin);
|
| 88 |
const [rangeMax, setRangeMax] = useState(globalMax);
|
| 89 |
|
| 90 |
-
const outsideIds = useMemo(
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
|
| 95 |
const rangeChanged = rangeMin > globalMin || rangeMax < globalMax;
|
| 96 |
-
const step =
|
|
|
|
|
|
|
| 97 |
|
| 98 |
return (
|
| 99 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 100 |
-
<h3 className="text-sm font-semibold text-slate-200">
|
|
|
|
|
|
|
| 101 |
|
| 102 |
<div className="space-y-2">
|
| 103 |
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
@@ -106,28 +169,49 @@ function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
|
|
| 106 |
</div>
|
| 107 |
<div className="relative h-5">
|
| 108 |
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
|
| 109 |
-
<div
|
|
|
|
| 110 |
style={{
|
| 111 |
left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 112 |
right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 113 |
-
}}
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
</div>
|
| 121 |
</div>
|
| 122 |
|
| 123 |
{rangeChanged && (
|
| 124 |
<div className="flex items-center justify-between">
|
| 125 |
<span className="text-xs text-slate-400">
|
| 126 |
-
{outsideIds.length} episode{outsideIds.length !== 1 ? "s" : ""}
|
|
|
|
| 127 |
</span>
|
| 128 |
{outsideIds.length > 0 && (
|
| 129 |
-
<button
|
| 130 |
-
|
|
|
|
|
|
|
| 131 |
Flag {outsideIds.length} outside range
|
| 132 |
</button>
|
| 133 |
)}
|
|
@@ -148,7 +232,13 @@ interface FilteringPanelProps {
|
|
| 148 |
onViewFlaggedEpisodes?: () => void;
|
| 149 |
}
|
| 150 |
|
| 151 |
-
function FlaggedIdsCopyBar({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
const { flagged, count, clear } = useFlaggedEpisodes();
|
| 153 |
const [copied, setCopied] = useState(false);
|
| 154 |
|
|
@@ -168,36 +258,89 @@ function FlaggedIdsCopyBar({ repoId, onViewEpisodes }: { repoId: string; onViewE
|
|
| 168 |
<div className="flex items-center justify-between">
|
| 169 |
<h3 className="text-sm font-semibold text-orange-400">
|
| 170 |
Flagged Episodes
|
| 171 |
-
<span className="text-xs text-slate-500 ml-2 font-normal">
|
|
|
|
|
|
|
| 172 |
</h3>
|
| 173 |
<div className="flex items-center gap-2">
|
| 174 |
-
<button
|
|
|
|
| 175 |
className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1"
|
| 176 |
-
title="Copy IDs"
|
|
|
|
| 177 |
{copied ? (
|
| 178 |
-
<svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
) : (
|
| 180 |
-
<svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
)}
|
| 182 |
Copy
|
| 183 |
</button>
|
| 184 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 185 |
</div>
|
| 186 |
</div>
|
| 187 |
-
<p className="text-xs text-slate-300 tabular-nums leading-relaxed max-h-20 overflow-y-auto">
|
|
|
|
|
|
|
| 188 |
{onViewEpisodes && (
|
| 189 |
-
<button
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 194 |
</svg>
|
| 195 |
View flagged episodes
|
| 196 |
</button>
|
| 197 |
)}
|
| 198 |
<div className="bg-slate-900/60 rounded-md px-3 py-2 border border-slate-700/60 space-y-2.5">
|
| 199 |
<p className="text-xs text-slate-400">
|
| 200 |
-
<a
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
</p>
|
| 202 |
<pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes (modifies original dataset)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
|
| 203 |
<pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes and save to a new dataset (preserves original)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --new_repo_id ${repoId}_filtered \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
|
|
@@ -219,11 +362,15 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
|
|
| 219 |
<div>
|
| 220 |
<h2 className="text-xl font-bold text-slate-100">Filtering</h2>
|
| 221 |
<p className="text-sm text-slate-400 mt-1">
|
| 222 |
-
Identify and flag problematic episodes for removal. Flagged episodes
|
|
|
|
| 223 |
</p>
|
| 224 |
</div>
|
| 225 |
|
| 226 |
-
<FlaggedIdsCopyBar
|
|
|
|
|
|
|
|
|
|
| 227 |
|
| 228 |
{episodeLengthStats?.allEpisodeLengths && (
|
| 229 |
<EpisodeLengthFilter episodes={episodeLengthStats.allEpisodeLengths} />
|
|
@@ -232,9 +379,24 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
|
|
| 232 |
{crossEpisodeLoading && (
|
| 233 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 234 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
|
| 235 |
-
<svg
|
| 236 |
-
|
| 237 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
</svg>
|
| 239 |
Loading cross-episode data…
|
| 240 |
</div>
|
|
@@ -258,4 +420,3 @@ const FilteringPanel: React.FC<FilteringPanelProps> = ({
|
|
| 258 |
};
|
| 259 |
|
| 260 |
export default FilteringPanel;
|
| 261 |
-
|
|
|
|
| 8 |
EpisodeLengthStats,
|
| 9 |
EpisodeLengthInfo,
|
| 10 |
} from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 11 |
+
import {
|
| 12 |
+
ActionVelocitySection,
|
| 13 |
+
FullscreenWrapper,
|
| 14 |
+
} from "@/components/action-insights-panel";
|
| 15 |
|
| 16 |
// ─── Shared small components ─────────────────────────────────────
|
| 17 |
|
|
|
|
| 19 |
const { has, toggle } = useFlaggedEpisodes();
|
| 20 |
const flagged = has(id);
|
| 21 |
return (
|
| 22 |
+
<button
|
| 23 |
+
onClick={() => toggle(id)}
|
| 24 |
+
title={flagged ? "Unflag episode" : "Flag for review"}
|
| 25 |
+
className={`p-0.5 rounded transition-colors ${flagged ? "text-orange-400" : "text-slate-600 hover:text-slate-400"}`}
|
| 26 |
+
>
|
| 27 |
+
<svg
|
| 28 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 29 |
+
width="12"
|
| 30 |
+
height="12"
|
| 31 |
+
viewBox="0 0 24 24"
|
| 32 |
+
fill={flagged ? "currentColor" : "none"}
|
| 33 |
+
stroke="currentColor"
|
| 34 |
+
strokeWidth="2"
|
| 35 |
+
strokeLinecap="round"
|
| 36 |
+
strokeLinejoin="round"
|
| 37 |
+
>
|
| 38 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 39 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 40 |
</svg>
|
| 41 |
</button>
|
| 42 |
);
|
|
|
|
| 45 |
function FlagAllBtn({ ids, label }: { ids: number[]; label?: string }) {
|
| 46 |
const { addMany } = useFlaggedEpisodes();
|
| 47 |
return (
|
| 48 |
+
<button
|
| 49 |
+
onClick={() => addMany(ids)}
|
| 50 |
+
className="text-xs text-slate-500 hover:text-orange-400 transition-colors flex items-center gap-1"
|
| 51 |
+
>
|
| 52 |
+
<svg
|
| 53 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 54 |
+
width="10"
|
| 55 |
+
height="10"
|
| 56 |
+
viewBox="0 0 24 24"
|
| 57 |
+
fill="none"
|
| 58 |
+
stroke="currentColor"
|
| 59 |
+
strokeWidth="2"
|
| 60 |
+
strokeLinecap="round"
|
| 61 |
+
strokeLinejoin="round"
|
| 62 |
+
>
|
| 63 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 64 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 65 |
</svg>
|
| 66 |
{label ?? "Flag all"}
|
| 67 |
</button>
|
|
|
|
| 72 |
|
| 73 |
function LowMovementSection({ episodes }: { episodes: LowMovementEpisode[] }) {
|
| 74 |
if (episodes.length === 0) return null;
|
| 75 |
+
const maxMovement = Math.max(...episodes.map((e) => e.totalMovement), 1e-10);
|
| 76 |
|
| 77 |
return (
|
| 78 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-3">
|
| 79 |
<div className="flex items-center justify-between">
|
| 80 |
+
<h3 className="text-sm font-semibold text-slate-200">
|
| 81 |
+
Lowest-Movement Episodes
|
| 82 |
+
</h3>
|
| 83 |
+
<FlagAllBtn ids={episodes.map((e) => e.episodeIndex)} />
|
| 84 |
</div>
|
| 85 |
<p className="text-xs text-slate-400">
|
| 86 |
+
Episodes with the lowest average action change per frame. Very low
|
| 87 |
+
values may indicate the robot was standing still or the episode was
|
| 88 |
+
recorded incorrectly.
|
| 89 |
</p>
|
| 90 |
+
<div
|
| 91 |
+
className="grid gap-2"
|
| 92 |
+
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(220px, 1fr))" }}
|
| 93 |
+
>
|
| 94 |
+
{episodes.map((ep) => (
|
| 95 |
+
<div
|
| 96 |
+
key={ep.episodeIndex}
|
| 97 |
+
className="bg-slate-900/50 rounded-md px-3 py-2 flex items-center gap-3"
|
| 98 |
+
>
|
| 99 |
<FlagBtn id={ep.episodeIndex} />
|
| 100 |
+
<span className="text-xs text-slate-300 font-medium shrink-0">
|
| 101 |
+
ep {ep.episodeIndex}
|
| 102 |
+
</span>
|
| 103 |
<div className="flex-1 min-w-0">
|
| 104 |
<div className="h-1.5 bg-slate-700 rounded-full overflow-hidden">
|
| 105 |
+
<div
|
| 106 |
+
className="h-full rounded-full"
|
| 107 |
style={{
|
| 108 |
width: `${Math.max(2, (ep.totalMovement / maxMovement) * 100)}%`,
|
| 109 |
+
background:
|
| 110 |
+
ep.totalMovement / maxMovement < 0.15
|
| 111 |
+
? "#ef4444"
|
| 112 |
+
: ep.totalMovement / maxMovement < 0.4
|
| 113 |
+
? "#eab308"
|
| 114 |
+
: "#22c55e",
|
| 115 |
+
}}
|
| 116 |
+
/>
|
| 117 |
</div>
|
| 118 |
</div>
|
| 119 |
+
<span className="text-xs text-slate-500 tabular-nums shrink-0">
|
| 120 |
+
{ep.totalMovement.toFixed(2)}
|
| 121 |
+
</span>
|
| 122 |
</div>
|
| 123 |
))}
|
| 124 |
</div>
|
|
|
|
| 130 |
|
| 131 |
function EpisodeLengthFilter({ episodes }: { episodes: EpisodeLengthInfo[] }) {
|
| 132 |
const { addMany } = useFlaggedEpisodes();
|
| 133 |
+
const globalMin = useMemo(
|
| 134 |
+
() => Math.min(...episodes.map((e) => e.lengthSeconds)),
|
| 135 |
+
[episodes],
|
| 136 |
+
);
|
| 137 |
+
const globalMax = useMemo(
|
| 138 |
+
() => Math.max(...episodes.map((e) => e.lengthSeconds)),
|
| 139 |
+
[episodes],
|
| 140 |
+
);
|
| 141 |
|
| 142 |
const [rangeMin, setRangeMin] = useState(globalMin);
|
| 143 |
const [rangeMax, setRangeMax] = useState(globalMax);
|
| 144 |
|
| 145 |
+
const outsideIds = useMemo(
|
| 146 |
+
() =>
|
| 147 |
+
episodes
|
| 148 |
+
.filter((e) => e.lengthSeconds < rangeMin || e.lengthSeconds > rangeMax)
|
| 149 |
+
.map((e) => e.episodeIndex)
|
| 150 |
+
.sort((a, b) => a - b),
|
| 151 |
+
[episodes, rangeMin, rangeMax],
|
| 152 |
+
);
|
| 153 |
|
| 154 |
const rangeChanged = rangeMin > globalMin || rangeMax < globalMax;
|
| 155 |
+
const step =
|
| 156 |
+
Math.max(0.01, Math.round((globalMax - globalMin) * 0.001 * 100) / 100) ||
|
| 157 |
+
0.01;
|
| 158 |
|
| 159 |
return (
|
| 160 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700 space-y-4">
|
| 161 |
+
<h3 className="text-sm font-semibold text-slate-200">
|
| 162 |
+
Episode Length Filter
|
| 163 |
+
</h3>
|
| 164 |
|
| 165 |
<div className="space-y-2">
|
| 166 |
<div className="flex items-center justify-between text-xs text-slate-400">
|
|
|
|
| 169 |
</div>
|
| 170 |
<div className="relative h-5">
|
| 171 |
<div className="absolute top-1/2 -translate-y-1/2 left-0 right-0 h-1 rounded bg-slate-700" />
|
| 172 |
+
<div
|
| 173 |
+
className="absolute top-1/2 -translate-y-1/2 h-1 rounded bg-orange-500"
|
| 174 |
style={{
|
| 175 |
left: `${((rangeMin - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 176 |
right: `${100 - ((rangeMax - globalMin) / (globalMax - globalMin || 1)) * 100}%`,
|
| 177 |
+
}}
|
| 178 |
+
/>
|
| 179 |
+
<input
|
| 180 |
+
type="range"
|
| 181 |
+
min={globalMin}
|
| 182 |
+
max={globalMax}
|
| 183 |
+
step={step}
|
| 184 |
+
value={rangeMin}
|
| 185 |
+
onChange={(e) =>
|
| 186 |
+
setRangeMin(Math.min(Number(e.target.value), rangeMax))
|
| 187 |
+
}
|
| 188 |
+
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"
|
| 189 |
+
/>
|
| 190 |
+
<input
|
| 191 |
+
type="range"
|
| 192 |
+
min={globalMin}
|
| 193 |
+
max={globalMax}
|
| 194 |
+
step={step}
|
| 195 |
+
value={rangeMax}
|
| 196 |
+
onChange={(e) =>
|
| 197 |
+
setRangeMax(Math.max(Number(e.target.value), rangeMin))
|
| 198 |
+
}
|
| 199 |
+
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"
|
| 200 |
+
/>
|
| 201 |
</div>
|
| 202 |
</div>
|
| 203 |
|
| 204 |
{rangeChanged && (
|
| 205 |
<div className="flex items-center justify-between">
|
| 206 |
<span className="text-xs text-slate-400">
|
| 207 |
+
{outsideIds.length} episode{outsideIds.length !== 1 ? "s" : ""}{" "}
|
| 208 |
+
outside range
|
| 209 |
</span>
|
| 210 |
{outsideIds.length > 0 && (
|
| 211 |
+
<button
|
| 212 |
+
onClick={() => addMany(outsideIds)}
|
| 213 |
+
className="text-xs bg-orange-500/20 text-orange-400 border border-orange-500/40 rounded px-2 py-1 hover:bg-orange-500/30 transition-colors"
|
| 214 |
+
>
|
| 215 |
Flag {outsideIds.length} outside range
|
| 216 |
</button>
|
| 217 |
)}
|
|
|
|
| 232 |
onViewFlaggedEpisodes?: () => void;
|
| 233 |
}
|
| 234 |
|
| 235 |
+
function FlaggedIdsCopyBar({
|
| 236 |
+
repoId,
|
| 237 |
+
onViewEpisodes,
|
| 238 |
+
}: {
|
| 239 |
+
repoId: string;
|
| 240 |
+
onViewEpisodes?: () => void;
|
| 241 |
+
}) {
|
| 242 |
const { flagged, count, clear } = useFlaggedEpisodes();
|
| 243 |
const [copied, setCopied] = useState(false);
|
| 244 |
|
|
|
|
| 258 |
<div className="flex items-center justify-between">
|
| 259 |
<h3 className="text-sm font-semibold text-orange-400">
|
| 260 |
Flagged Episodes
|
| 261 |
+
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 262 |
+
({count})
|
| 263 |
+
</span>
|
| 264 |
</h3>
|
| 265 |
<div className="flex items-center gap-2">
|
| 266 |
+
<button
|
| 267 |
+
onClick={handleCopy}
|
| 268 |
className="text-xs text-slate-400 hover:text-slate-200 transition-colors flex items-center gap-1"
|
| 269 |
+
title="Copy IDs"
|
| 270 |
+
>
|
| 271 |
{copied ? (
|
| 272 |
+
<svg
|
| 273 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 274 |
+
width="12"
|
| 275 |
+
height="12"
|
| 276 |
+
viewBox="0 0 24 24"
|
| 277 |
+
fill="none"
|
| 278 |
+
stroke="currentColor"
|
| 279 |
+
strokeWidth="2"
|
| 280 |
+
className="text-green-400"
|
| 281 |
+
>
|
| 282 |
+
<polyline points="20 6 9 17 4 12" />
|
| 283 |
+
</svg>
|
| 284 |
) : (
|
| 285 |
+
<svg
|
| 286 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 287 |
+
width="12"
|
| 288 |
+
height="12"
|
| 289 |
+
viewBox="0 0 24 24"
|
| 290 |
+
fill="none"
|
| 291 |
+
stroke="currentColor"
|
| 292 |
+
strokeWidth="2"
|
| 293 |
+
>
|
| 294 |
+
<rect x="9" y="9" width="13" height="13" rx="2" />
|
| 295 |
+
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
|
| 296 |
+
</svg>
|
| 297 |
)}
|
| 298 |
Copy
|
| 299 |
</button>
|
| 300 |
+
<button
|
| 301 |
+
onClick={clear}
|
| 302 |
+
className="text-xs text-slate-500 hover:text-red-400 transition-colors"
|
| 303 |
+
>
|
| 304 |
+
Clear
|
| 305 |
+
</button>
|
| 306 |
</div>
|
| 307 |
</div>
|
| 308 |
+
<p className="text-xs text-slate-300 tabular-nums leading-relaxed max-h-20 overflow-y-auto">
|
| 309 |
+
{idStr}
|
| 310 |
+
</p>
|
| 311 |
{onViewEpisodes && (
|
| 312 |
+
<button
|
| 313 |
+
onClick={onViewEpisodes}
|
| 314 |
+
className="w-full text-xs py-1.5 rounded bg-slate-700/80 hover:bg-slate-600 text-slate-300 hover:text-white transition-colors flex items-center justify-center gap-1.5"
|
| 315 |
+
>
|
| 316 |
+
<svg
|
| 317 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 318 |
+
width="12"
|
| 319 |
+
height="12"
|
| 320 |
+
viewBox="0 0 24 24"
|
| 321 |
+
fill="none"
|
| 322 |
+
stroke="currentColor"
|
| 323 |
+
strokeWidth="2"
|
| 324 |
+
strokeLinecap="round"
|
| 325 |
+
strokeLinejoin="round"
|
| 326 |
+
>
|
| 327 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 328 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 329 |
</svg>
|
| 330 |
View flagged episodes
|
| 331 |
</button>
|
| 332 |
)}
|
| 333 |
<div className="bg-slate-900/60 rounded-md px-3 py-2 border border-slate-700/60 space-y-2.5">
|
| 334 |
<p className="text-xs text-slate-400">
|
| 335 |
+
<a
|
| 336 |
+
href="https://github.com/huggingface/lerobot"
|
| 337 |
+
target="_blank"
|
| 338 |
+
rel="noopener noreferrer"
|
| 339 |
+
className="text-orange-400 underline"
|
| 340 |
+
>
|
| 341 |
+
LeRobot CLI
|
| 342 |
+
</a>{" "}
|
| 343 |
+
— delete flagged episodes:
|
| 344 |
</p>
|
| 345 |
<pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes (modifies original dataset)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
|
| 346 |
<pre className="text-xs text-slate-300 bg-slate-950/50 rounded px-2 py-1.5 overflow-x-auto select-all">{`# Delete episodes and save to a new dataset (preserves original)\nlerobot-edit-dataset \\\n --repo_id ${repoId} \\\n --new_repo_id ${repoId}_filtered \\\n --operation.type delete_episodes \\\n --operation.episode_indices "[${ids.join(", ")}]"`}</pre>
|
|
|
|
| 362 |
<div>
|
| 363 |
<h2 className="text-xl font-bold text-slate-100">Filtering</h2>
|
| 364 |
<p className="text-sm text-slate-400 mt-1">
|
| 365 |
+
Identify and flag problematic episodes for removal. Flagged episodes
|
| 366 |
+
appear in the sidebar and can be exported as a CLI command.
|
| 367 |
</p>
|
| 368 |
</div>
|
| 369 |
|
| 370 |
+
<FlaggedIdsCopyBar
|
| 371 |
+
repoId={repoId}
|
| 372 |
+
onViewEpisodes={onViewFlaggedEpisodes}
|
| 373 |
+
/>
|
| 374 |
|
| 375 |
{episodeLengthStats?.allEpisodeLengths && (
|
| 376 |
<EpisodeLengthFilter episodes={episodeLengthStats.allEpisodeLengths} />
|
|
|
|
| 379 |
{crossEpisodeLoading && (
|
| 380 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 381 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-4 justify-center">
|
| 382 |
+
<svg
|
| 383 |
+
className="animate-spin h-4 w-4"
|
| 384 |
+
viewBox="0 0 24 24"
|
| 385 |
+
fill="none"
|
| 386 |
+
>
|
| 387 |
+
<circle
|
| 388 |
+
className="opacity-25"
|
| 389 |
+
cx="12"
|
| 390 |
+
cy="12"
|
| 391 |
+
r="10"
|
| 392 |
+
stroke="currentColor"
|
| 393 |
+
strokeWidth="4"
|
| 394 |
+
/>
|
| 395 |
+
<path
|
| 396 |
+
className="opacity-75"
|
| 397 |
+
fill="currentColor"
|
| 398 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
| 399 |
+
/>
|
| 400 |
</svg>
|
| 401 |
Loading cross-episode data…
|
| 402 |
</div>
|
|
|
|
| 420 |
};
|
| 421 |
|
| 422 |
export default FilteringPanel;
|
|
|
src/components/overview-panel.tsx
CHANGED
|
@@ -1,12 +1,21 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect, useRef, useCallback } from "react";
|
| 4 |
-
import type {
|
|
|
|
|
|
|
|
|
|
| 5 |
import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
|
| 6 |
|
| 7 |
const PAGE_SIZE = 48;
|
| 8 |
|
| 9 |
-
function FrameThumbnail({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 11 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 12 |
const [inView, setInView] = useState(false);
|
|
@@ -15,7 +24,12 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
|
|
| 15 |
const el = containerRef.current;
|
| 16 |
if (!el) return;
|
| 17 |
const obs = new IntersectionObserver(
|
| 18 |
-
([e]) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
{ rootMargin: "200px" },
|
| 20 |
);
|
| 21 |
obs.observe(el);
|
|
@@ -28,7 +42,8 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
|
|
| 28 |
|
| 29 |
const seek = () => {
|
| 30 |
if (showLast) {
|
| 31 |
-
video.currentTime =
|
|
|
|
| 32 |
} else {
|
| 33 |
video.currentTime = info.firstFrameTime;
|
| 34 |
}
|
|
@@ -62,18 +77,33 @@ function FrameThumbnail({ info, showLast }: { info: EpisodeFrameInfo; showLast:
|
|
| 62 |
<button
|
| 63 |
onClick={() => toggle(info.episodeIndex)}
|
| 64 |
className={`absolute top-1 right-1 p-1 rounded transition-opacity ${
|
| 65 |
-
isFlagged
|
|
|
|
|
|
|
| 66 |
}`}
|
| 67 |
title={isFlagged ? "Unflag episode" : "Flag episode"}
|
| 68 |
>
|
| 69 |
-
<svg
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
</svg>
|
| 73 |
</button>
|
| 74 |
</div>
|
| 75 |
-
<p
|
| 76 |
-
|
|
|
|
|
|
|
|
|
|
| 77 |
</p>
|
| 78 |
</div>
|
| 79 |
);
|
|
@@ -86,7 +116,12 @@ interface OverviewPanelProps {
|
|
| 86 |
onFlaggedOnlyChange?: (v: boolean) => void;
|
| 87 |
}
|
| 88 |
|
| 89 |
-
export default function OverviewPanel({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
const { flagged, count: flagCount } = useFlaggedEpisodes();
|
| 91 |
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
| 92 |
const [showLast, setShowLast] = useState(false);
|
|
@@ -99,17 +134,31 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 99 |
}
|
| 100 |
}, [data, selectedCamera]);
|
| 101 |
|
| 102 |
-
const handleCameraChange = useCallback(
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
|
|
|
|
|
|
|
|
|
| 106 |
|
| 107 |
if (loading || !data) {
|
| 108 |
return (
|
| 109 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
| 110 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 111 |
-
<circle
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
</svg>
|
| 114 |
Loading episode frames…
|
| 115 |
</div>
|
|
@@ -117,14 +166,25 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 117 |
}
|
| 118 |
|
| 119 |
const allFrames = data.framesByCamera[selectedCamera] ?? [];
|
| 120 |
-
const frames = flaggedOnly
|
|
|
|
|
|
|
| 121 |
|
| 122 |
if (frames.length === 0) {
|
| 123 |
return (
|
| 124 |
<div className="text-center py-8 space-y-2">
|
| 125 |
-
<p className="text-slate-500 italic">
|
|
|
|
|
|
|
|
|
|
|
|
|
| 126 |
{flaggedOnly && onFlaggedOnlyChange && (
|
| 127 |
-
<button
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
)}
|
| 129 |
</div>
|
| 130 |
);
|
|
@@ -136,7 +196,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 136 |
return (
|
| 137 |
<div className="max-w-7xl mx-auto py-6 space-y-5">
|
| 138 |
<p className="text-sm text-slate-500">
|
| 139 |
-
Use first/last frame views to spot episodes with bad end states or other
|
|
|
|
|
|
|
| 140 |
</p>
|
| 141 |
|
| 142 |
{/* Controls row */}
|
|
@@ -150,7 +212,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 150 |
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"
|
| 151 |
>
|
| 152 |
{data.cameras.map((cam) => (
|
| 153 |
-
<option key={cam} value={cam}>
|
|
|
|
|
|
|
| 154 |
))}
|
| 155 |
</select>
|
| 156 |
)}
|
|
@@ -158,16 +222,29 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 158 |
{/* Flagged only toggle */}
|
| 159 |
{flagCount > 0 && onFlaggedOnlyChange && (
|
| 160 |
<button
|
| 161 |
-
onClick={() => {
|
|
|
|
|
|
|
|
|
|
| 162 |
className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
|
| 163 |
flaggedOnly
|
| 164 |
? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
|
| 165 |
: "text-slate-400 hover:text-slate-200 border border-slate-700"
|
| 166 |
}`}
|
| 167 |
>
|
| 168 |
-
<svg
|
| 169 |
-
|
| 170 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 171 |
</svg>
|
| 172 |
Flagged only ({flagCount})
|
| 173 |
</button>
|
|
@@ -175,7 +252,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 175 |
|
| 176 |
{/* First / Last toggle */}
|
| 177 |
<div className="flex items-center gap-3">
|
| 178 |
-
<span
|
|
|
|
|
|
|
| 179 |
First Frame
|
| 180 |
</span>
|
| 181 |
<button
|
|
@@ -187,7 +266,9 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 187 |
className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
| 188 |
/>
|
| 189 |
</button>
|
| 190 |
-
<span
|
|
|
|
|
|
|
| 191 |
Last Frame
|
| 192 |
</span>
|
| 193 |
</div>
|
|
@@ -218,9 +299,16 @@ export default function OverviewPanel({ data, loading, flaggedOnly = false, onFl
|
|
| 218 |
</div>
|
| 219 |
|
| 220 |
{/* Adaptive grid — only current page's thumbnails are mounted */}
|
| 221 |
-
<div
|
|
|
|
|
|
|
|
|
|
| 222 |
{pageFrames.map((info) => (
|
| 223 |
-
<FrameThumbnail
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
))}
|
| 225 |
</div>
|
| 226 |
</div>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
import React, { useState, useEffect, useRef, useCallback } from "react";
|
| 4 |
+
import type {
|
| 5 |
+
EpisodeFrameInfo,
|
| 6 |
+
EpisodeFramesData,
|
| 7 |
+
} from "@/app/[org]/[dataset]/[episode]/fetch-data";
|
| 8 |
import { useFlaggedEpisodes } from "@/context/flagged-episodes-context";
|
| 9 |
|
| 10 |
const PAGE_SIZE = 48;
|
| 11 |
|
| 12 |
+
function FrameThumbnail({
|
| 13 |
+
info,
|
| 14 |
+
showLast,
|
| 15 |
+
}: {
|
| 16 |
+
info: EpisodeFrameInfo;
|
| 17 |
+
showLast: boolean;
|
| 18 |
+
}) {
|
| 19 |
const containerRef = useRef<HTMLDivElement>(null);
|
| 20 |
const videoRef = useRef<HTMLVideoElement>(null);
|
| 21 |
const [inView, setInView] = useState(false);
|
|
|
|
| 24 |
const el = containerRef.current;
|
| 25 |
if (!el) return;
|
| 26 |
const obs = new IntersectionObserver(
|
| 27 |
+
([e]) => {
|
| 28 |
+
if (e.isIntersecting) {
|
| 29 |
+
setInView(true);
|
| 30 |
+
obs.disconnect();
|
| 31 |
+
}
|
| 32 |
+
},
|
| 33 |
{ rootMargin: "200px" },
|
| 34 |
);
|
| 35 |
obs.observe(el);
|
|
|
|
| 42 |
|
| 43 |
const seek = () => {
|
| 44 |
if (showLast) {
|
| 45 |
+
video.currentTime =
|
| 46 |
+
info.lastFrameTime ?? Math.max(0, video.duration - 0.05);
|
| 47 |
} else {
|
| 48 |
video.currentTime = info.firstFrameTime;
|
| 49 |
}
|
|
|
|
| 77 |
<button
|
| 78 |
onClick={() => toggle(info.episodeIndex)}
|
| 79 |
className={`absolute top-1 right-1 p-1 rounded transition-opacity ${
|
| 80 |
+
isFlagged
|
| 81 |
+
? "opacity-100 text-orange-400"
|
| 82 |
+
: "opacity-0 group-hover:opacity-100 text-slate-400 hover:text-orange-400"
|
| 83 |
}`}
|
| 84 |
title={isFlagged ? "Unflag episode" : "Flag episode"}
|
| 85 |
>
|
| 86 |
+
<svg
|
| 87 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 88 |
+
width="14"
|
| 89 |
+
height="14"
|
| 90 |
+
viewBox="0 0 24 24"
|
| 91 |
+
fill={isFlagged ? "currentColor" : "none"}
|
| 92 |
+
stroke="currentColor"
|
| 93 |
+
strokeWidth="2"
|
| 94 |
+
strokeLinecap="round"
|
| 95 |
+
strokeLinejoin="round"
|
| 96 |
+
>
|
| 97 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 98 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 99 |
</svg>
|
| 100 |
</button>
|
| 101 |
</div>
|
| 102 |
+
<p
|
| 103 |
+
className={`text-xs mt-1 tabular-nums ${isFlagged ? "text-orange-400" : "text-slate-400"}`}
|
| 104 |
+
>
|
| 105 |
+
ep {info.episodeIndex}
|
| 106 |
+
{isFlagged ? " ⚑" : ""}
|
| 107 |
</p>
|
| 108 |
</div>
|
| 109 |
);
|
|
|
|
| 116 |
onFlaggedOnlyChange?: (v: boolean) => void;
|
| 117 |
}
|
| 118 |
|
| 119 |
+
export default function OverviewPanel({
|
| 120 |
+
data,
|
| 121 |
+
loading,
|
| 122 |
+
flaggedOnly = false,
|
| 123 |
+
onFlaggedOnlyChange,
|
| 124 |
+
}: OverviewPanelProps) {
|
| 125 |
const { flagged, count: flagCount } = useFlaggedEpisodes();
|
| 126 |
const [selectedCamera, setSelectedCamera] = useState<string>("");
|
| 127 |
const [showLast, setShowLast] = useState(false);
|
|
|
|
| 134 |
}
|
| 135 |
}, [data, selectedCamera]);
|
| 136 |
|
| 137 |
+
const handleCameraChange = useCallback(
|
| 138 |
+
(e: React.ChangeEvent<HTMLSelectElement>) => {
|
| 139 |
+
setSelectedCamera(e.target.value);
|
| 140 |
+
setPage(0);
|
| 141 |
+
},
|
| 142 |
+
[],
|
| 143 |
+
);
|
| 144 |
|
| 145 |
if (loading || !data) {
|
| 146 |
return (
|
| 147 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-12 justify-center">
|
| 148 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 149 |
+
<circle
|
| 150 |
+
className="opacity-25"
|
| 151 |
+
cx="12"
|
| 152 |
+
cy="12"
|
| 153 |
+
r="10"
|
| 154 |
+
stroke="currentColor"
|
| 155 |
+
strokeWidth="4"
|
| 156 |
+
/>
|
| 157 |
+
<path
|
| 158 |
+
className="opacity-75"
|
| 159 |
+
fill="currentColor"
|
| 160 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
| 161 |
+
/>
|
| 162 |
</svg>
|
| 163 |
Loading episode frames…
|
| 164 |
</div>
|
|
|
|
| 166 |
}
|
| 167 |
|
| 168 |
const allFrames = data.framesByCamera[selectedCamera] ?? [];
|
| 169 |
+
const frames = flaggedOnly
|
| 170 |
+
? allFrames.filter((f) => flagged.has(f.episodeIndex))
|
| 171 |
+
: allFrames;
|
| 172 |
|
| 173 |
if (frames.length === 0) {
|
| 174 |
return (
|
| 175 |
<div className="text-center py-8 space-y-2">
|
| 176 |
+
<p className="text-slate-500 italic">
|
| 177 |
+
{flaggedOnly
|
| 178 |
+
? "No flagged episodes to show."
|
| 179 |
+
: "No episode frames available."}
|
| 180 |
+
</p>
|
| 181 |
{flaggedOnly && onFlaggedOnlyChange && (
|
| 182 |
+
<button
|
| 183 |
+
onClick={() => onFlaggedOnlyChange(false)}
|
| 184 |
+
className="text-xs text-orange-400 hover:text-orange-300 underline"
|
| 185 |
+
>
|
| 186 |
+
Show all episodes
|
| 187 |
+
</button>
|
| 188 |
)}
|
| 189 |
</div>
|
| 190 |
);
|
|
|
|
| 196 |
return (
|
| 197 |
<div className="max-w-7xl mx-auto py-6 space-y-5">
|
| 198 |
<p className="text-sm text-slate-500">
|
| 199 |
+
Use first/last frame views to spot episodes with bad end states or other
|
| 200 |
+
anomalies. Hover over a thumbnail and click the flag icon to mark
|
| 201 |
+
episodes with wrong outcomes for review.
|
| 202 |
</p>
|
| 203 |
|
| 204 |
{/* Controls row */}
|
|
|
|
| 212 |
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"
|
| 213 |
>
|
| 214 |
{data.cameras.map((cam) => (
|
| 215 |
+
<option key={cam} value={cam}>
|
| 216 |
+
{cam}
|
| 217 |
+
</option>
|
| 218 |
))}
|
| 219 |
</select>
|
| 220 |
)}
|
|
|
|
| 222 |
{/* Flagged only toggle */}
|
| 223 |
{flagCount > 0 && onFlaggedOnlyChange && (
|
| 224 |
<button
|
| 225 |
+
onClick={() => {
|
| 226 |
+
onFlaggedOnlyChange(!flaggedOnly);
|
| 227 |
+
setPage(0);
|
| 228 |
+
}}
|
| 229 |
className={`text-xs px-2.5 py-1 rounded transition-colors flex items-center gap-1.5 ${
|
| 230 |
flaggedOnly
|
| 231 |
? "bg-orange-500/20 text-orange-400 border border-orange-500/40"
|
| 232 |
: "text-slate-400 hover:text-slate-200 border border-slate-700"
|
| 233 |
}`}
|
| 234 |
>
|
| 235 |
+
<svg
|
| 236 |
+
xmlns="http://www.w3.org/2000/svg"
|
| 237 |
+
width="12"
|
| 238 |
+
height="12"
|
| 239 |
+
viewBox="0 0 24 24"
|
| 240 |
+
fill={flaggedOnly ? "currentColor" : "none"}
|
| 241 |
+
stroke="currentColor"
|
| 242 |
+
strokeWidth="2"
|
| 243 |
+
strokeLinecap="round"
|
| 244 |
+
strokeLinejoin="round"
|
| 245 |
+
>
|
| 246 |
+
<path d="M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" />
|
| 247 |
+
<line x1="4" y1="22" x2="4" y2="15" />
|
| 248 |
</svg>
|
| 249 |
Flagged only ({flagCount})
|
| 250 |
</button>
|
|
|
|
| 252 |
|
| 253 |
{/* First / Last toggle */}
|
| 254 |
<div className="flex items-center gap-3">
|
| 255 |
+
<span
|
| 256 |
+
className={`text-sm ${!showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
|
| 257 |
+
>
|
| 258 |
First Frame
|
| 259 |
</span>
|
| 260 |
<button
|
|
|
|
| 266 |
className={`inline-block w-3.5 h-3.5 bg-white rounded-full transition-transform ${showLast ? "translate-x-[18px]" : "translate-x-[3px]"}`}
|
| 267 |
/>
|
| 268 |
</button>
|
| 269 |
+
<span
|
| 270 |
+
className={`text-sm ${showLast ? "text-slate-100 font-medium" : "text-slate-500"}`}
|
| 271 |
+
>
|
| 272 |
Last Frame
|
| 273 |
</span>
|
| 274 |
</div>
|
|
|
|
| 299 |
</div>
|
| 300 |
|
| 301 |
{/* Adaptive grid — only current page's thumbnails are mounted */}
|
| 302 |
+
<div
|
| 303 |
+
className="grid gap-3"
|
| 304 |
+
style={{ gridTemplateColumns: "repeat(auto-fill, minmax(140px, 1fr))" }}
|
| 305 |
+
>
|
| 306 |
{pageFrames.map((info) => (
|
| 307 |
+
<FrameThumbnail
|
| 308 |
+
key={`${selectedCamera}-${info.episodeIndex}`}
|
| 309 |
+
info={info}
|
| 310 |
+
showLast={showLast}
|
| 311 |
+
/>
|
| 312 |
))}
|
| 313 |
</div>
|
| 314 |
</div>
|
src/components/side-nav.tsx
CHANGED
|
@@ -70,7 +70,10 @@ const Sidebar: React.FC<SidebarProps> = ({
|
|
| 70 |
<div className="ml-2 mt-1">
|
| 71 |
<ul>
|
| 72 |
{displayEpisodes.map((episode) => (
|
| 73 |
-
<li
|
|
|
|
|
|
|
|
|
|
| 74 |
<Link
|
| 75 |
href={`./episode_${episode}`}
|
| 76 |
className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
|
|
|
|
| 70 |
<div className="ml-2 mt-1">
|
| 71 |
<ul>
|
| 72 |
{displayEpisodes.map((episode) => (
|
| 73 |
+
<li
|
| 74 |
+
key={episode}
|
| 75 |
+
className="mt-0.5 font-mono text-sm flex items-center gap-1"
|
| 76 |
+
>
|
| 77 |
<Link
|
| 78 |
href={`./episode_${episode}`}
|
| 79 |
className={`underline ${episode === episodeId ? "-ml-1 font-bold" : ""}`}
|
src/components/simple-videos-player.tsx
CHANGED
|
@@ -80,13 +80,13 @@ export const SimpleVideosPlayer = ({
|
|
| 80 |
video.currentTime = info.segmentStart || 0;
|
| 81 |
checkReady();
|
| 82 |
};
|
| 83 |
-
|
| 84 |
-
video.addEventListener(
|
| 85 |
-
video.addEventListener(
|
| 86 |
-
|
| 87 |
videoEventCleanup.set(video, () => {
|
| 88 |
-
video.removeEventListener(
|
| 89 |
-
video.removeEventListener(
|
| 90 |
});
|
| 91 |
} else {
|
| 92 |
// For non-segmented videos, handle end of video
|
|
@@ -96,12 +96,12 @@ export const SimpleVideosPlayer = ({
|
|
| 96 |
setCurrentTime(0);
|
| 97 |
}
|
| 98 |
};
|
| 99 |
-
|
| 100 |
-
video.addEventListener(
|
| 101 |
-
video.addEventListener(
|
| 102 |
-
|
| 103 |
videoEventCleanup.set(video, () => {
|
| 104 |
-
video.removeEventListener(
|
| 105 |
});
|
| 106 |
}
|
| 107 |
}
|
|
@@ -150,8 +150,9 @@ export const SimpleVideosPlayer = ({
|
|
| 150 |
useEffect(() => {
|
| 151 |
if (!videosReady) return;
|
| 152 |
|
| 153 |
-
const isExternalSeek =
|
| 154 |
-
|
|
|
|
| 155 |
videoRefs.current.forEach((video, index) => {
|
| 156 |
if (!video) return;
|
| 157 |
if (hiddenVideos.includes(videosInfo[index].filename)) return;
|
|
@@ -164,7 +165,7 @@ export const SimpleVideosPlayer = ({
|
|
| 164 |
if (info.isSegmented) {
|
| 165 |
targetTime = (info.segmentStart || 0) + currentTime;
|
| 166 |
}
|
| 167 |
-
|
| 168 |
if (Math.abs(video.currentTime - targetTime) > 0.2) {
|
| 169 |
video.currentTime = targetTime;
|
| 170 |
}
|
|
@@ -280,7 +281,9 @@ export const SimpleVideosPlayer = ({
|
|
| 280 |
</span>
|
| 281 |
</p>
|
| 282 |
<video
|
| 283 |
-
ref={(el: HTMLVideoElement | null) => {
|
|
|
|
|
|
|
| 284 |
className={`w-full object-contain ${
|
| 285 |
isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
|
| 286 |
}`}
|
|
|
|
| 80 |
video.currentTime = info.segmentStart || 0;
|
| 81 |
checkReady();
|
| 82 |
};
|
| 83 |
+
|
| 84 |
+
video.addEventListener("timeupdate", handleTimeUpdate);
|
| 85 |
+
video.addEventListener("loadeddata", handleLoadedData);
|
| 86 |
+
|
| 87 |
videoEventCleanup.set(video, () => {
|
| 88 |
+
video.removeEventListener("timeupdate", handleTimeUpdate);
|
| 89 |
+
video.removeEventListener("loadeddata", handleLoadedData);
|
| 90 |
});
|
| 91 |
} else {
|
| 92 |
// For non-segmented videos, handle end of video
|
|
|
|
| 96 |
setCurrentTime(0);
|
| 97 |
}
|
| 98 |
};
|
| 99 |
+
|
| 100 |
+
video.addEventListener("ended", handleEnded);
|
| 101 |
+
video.addEventListener("canplaythrough", checkReady, { once: true });
|
| 102 |
+
|
| 103 |
videoEventCleanup.set(video, () => {
|
| 104 |
+
video.removeEventListener("ended", handleEnded);
|
| 105 |
});
|
| 106 |
}
|
| 107 |
}
|
|
|
|
| 150 |
useEffect(() => {
|
| 151 |
if (!videosReady) return;
|
| 152 |
|
| 153 |
+
const isExternalSeek =
|
| 154 |
+
Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
|
| 155 |
+
|
| 156 |
videoRefs.current.forEach((video, index) => {
|
| 157 |
if (!video) return;
|
| 158 |
if (hiddenVideos.includes(videosInfo[index].filename)) return;
|
|
|
|
| 165 |
if (info.isSegmented) {
|
| 166 |
targetTime = (info.segmentStart || 0) + currentTime;
|
| 167 |
}
|
| 168 |
+
|
| 169 |
if (Math.abs(video.currentTime - targetTime) > 0.2) {
|
| 170 |
video.currentTime = targetTime;
|
| 171 |
}
|
|
|
|
| 281 |
</span>
|
| 282 |
</p>
|
| 283 |
<video
|
| 284 |
+
ref={(el: HTMLVideoElement | null) => {
|
| 285 |
+
videoRefs.current[idx] = el;
|
| 286 |
+
}}
|
| 287 |
className={`w-full object-contain ${
|
| 288 |
isEnlarged ? "max-h-[90vh] max-w-[90vw]" : ""
|
| 289 |
}`}
|
src/components/stats-panel.tsx
CHANGED
|
@@ -22,14 +22,19 @@ function formatTotalTime(totalFrames: number, fps: number): string {
|
|
| 22 |
}
|
| 23 |
|
| 24 |
/** SVG bar chart for the episode-length histogram */
|
| 25 |
-
const EpisodeLengthHistogram: React.FC<{
|
|
|
|
|
|
|
| 26 |
if (data.length === 0) return null;
|
| 27 |
const maxCount = Math.max(...data.map((d) => d.count));
|
| 28 |
if (maxCount === 0) return null;
|
| 29 |
|
| 30 |
const totalWidth = 560;
|
| 31 |
const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
|
| 32 |
-
const barWidth = Math.max(
|
|
|
|
|
|
|
|
|
|
| 33 |
const chartHeight = 150;
|
| 34 |
const labelHeight = 30;
|
| 35 |
const topPad = 16;
|
|
@@ -38,7 +43,12 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
|
|
| 38 |
|
| 39 |
return (
|
| 40 |
<div className="overflow-x-auto">
|
| 41 |
-
<svg
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
{data.map((bin, i) => {
|
| 43 |
const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
|
| 44 |
const x = i * (barWidth + gap);
|
|
@@ -46,9 +56,22 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
|
|
| 46 |
return (
|
| 47 |
<g key={i}>
|
| 48 |
<title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
|
| 49 |
-
<rect
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 50 |
{bin.count > 0 && barWidth >= 8 && (
|
| 51 |
-
<text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
{bin.count}
|
| 53 |
</text>
|
| 54 |
)}
|
|
@@ -61,7 +84,14 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
|
|
| 61 |
if (!isFirst && !isLast && idx % labelStep !== 0) return null;
|
| 62 |
const label = bin.binLabel.split("–")[0];
|
| 63 |
return (
|
| 64 |
-
<text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
{label}s
|
| 66 |
</text>
|
| 67 |
);
|
|
@@ -71,7 +101,10 @@ const EpisodeLengthHistogram: React.FC<{ data: { binLabel: string; count: number
|
|
| 71 |
);
|
| 72 |
};
|
| 73 |
|
| 74 |
-
const Card: React.FC<{ label: string; value: string | number }> = ({
|
|
|
|
|
|
|
|
|
|
| 75 |
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
|
| 76 |
<p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
|
| 77 |
<p className="text-xl font-bold tabular-nums mt-1">{value}</p>
|
|
@@ -88,7 +121,12 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
| 88 |
return (
|
| 89 |
<div className="max-w-4xl mx-auto py-6 space-y-8">
|
| 90 |
<div>
|
| 91 |
-
<h2 className="text-xl text-slate-100">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
|
| 94 |
{/* Overview cards */}
|
|
@@ -99,21 +137,39 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
| 99 |
</div>
|
| 100 |
|
| 101 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 102 |
-
<Card
|
| 103 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
<Card label="FPS" value={datasetInfo.fps} />
|
| 105 |
-
<Card
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
|
| 108 |
{/* Camera resolutions */}
|
| 109 |
{datasetInfo.cameras.length > 0 && (
|
| 110 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 111 |
-
<h3 className="text-sm font-semibold text-slate-200 mb-3">
|
|
|
|
|
|
|
| 112 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 113 |
{datasetInfo.cameras.map((cam: CameraInfo) => (
|
| 114 |
<div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
|
| 115 |
-
<p
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
</div>
|
| 118 |
))}
|
| 119 |
</div>
|
|
@@ -124,8 +180,19 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
| 124 |
{loading && (
|
| 125 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-4">
|
| 126 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 127 |
-
<circle
|
| 128 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
</svg>
|
| 130 |
Computing episode statistics…
|
| 131 |
</div>
|
|
@@ -135,10 +202,18 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
| 135 |
{els && (
|
| 136 |
<>
|
| 137 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 138 |
-
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
|
|
|
|
|
|
| 139 |
<div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
|
| 140 |
-
<Card
|
| 141 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 142 |
<Card label="Mean" value={`${els.meanEpisodeLength}s`} />
|
| 143 |
<Card label="Median" value={`${els.medianEpisodeLength}s`} />
|
| 144 |
<Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
|
|
@@ -150,13 +225,13 @@ const StatsPanel: React.FC<StatsPanelProps> = ({
|
|
| 150 |
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
| 151 |
Episode Length Distribution
|
| 152 |
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 153 |
-
{els.episodeLengthHistogram.length} bin
|
|
|
|
| 154 |
</span>
|
| 155 |
</h3>
|
| 156 |
<EpisodeLengthHistogram data={els.episodeLengthHistogram} />
|
| 157 |
</div>
|
| 158 |
)}
|
| 159 |
-
|
| 160 |
</>
|
| 161 |
)}
|
| 162 |
</div>
|
|
|
|
| 22 |
}
|
| 23 |
|
| 24 |
/** SVG bar chart for the episode-length histogram */
|
| 25 |
+
const EpisodeLengthHistogram: React.FC<{
|
| 26 |
+
data: { binLabel: string; count: number }[];
|
| 27 |
+
}> = ({ data }) => {
|
| 28 |
if (data.length === 0) return null;
|
| 29 |
const maxCount = Math.max(...data.map((d) => d.count));
|
| 30 |
if (maxCount === 0) return null;
|
| 31 |
|
| 32 |
const totalWidth = 560;
|
| 33 |
const gap = Math.max(1, Math.min(3, Math.floor(60 / data.length)));
|
| 34 |
+
const barWidth = Math.max(
|
| 35 |
+
4,
|
| 36 |
+
Math.floor((totalWidth - gap * data.length) / data.length),
|
| 37 |
+
);
|
| 38 |
const chartHeight = 150;
|
| 39 |
const labelHeight = 30;
|
| 40 |
const topPad = 16;
|
|
|
|
| 43 |
|
| 44 |
return (
|
| 45 |
<div className="overflow-x-auto">
|
| 46 |
+
<svg
|
| 47 |
+
width={svgWidth}
|
| 48 |
+
height={topPad + chartHeight + labelHeight}
|
| 49 |
+
className="block"
|
| 50 |
+
aria-label="Episode length distribution histogram"
|
| 51 |
+
>
|
| 52 |
{data.map((bin, i) => {
|
| 53 |
const barH = Math.max(1, (bin.count / maxCount) * chartHeight);
|
| 54 |
const x = i * (barWidth + gap);
|
|
|
|
| 56 |
return (
|
| 57 |
<g key={i}>
|
| 58 |
<title>{`${bin.binLabel}: ${bin.count} episode${bin.count !== 1 ? "s" : ""}`}</title>
|
| 59 |
+
<rect
|
| 60 |
+
x={x}
|
| 61 |
+
y={y}
|
| 62 |
+
width={barWidth}
|
| 63 |
+
height={barH}
|
| 64 |
+
className="fill-orange-500/80 hover:fill-orange-400 transition-colors"
|
| 65 |
+
rx={Math.min(2, barWidth / 4)}
|
| 66 |
+
/>
|
| 67 |
{bin.count > 0 && barWidth >= 8 && (
|
| 68 |
+
<text
|
| 69 |
+
x={x + barWidth / 2}
|
| 70 |
+
y={y - 3}
|
| 71 |
+
textAnchor="middle"
|
| 72 |
+
className="fill-slate-400"
|
| 73 |
+
fontSize={Math.min(10, barWidth - 1)}
|
| 74 |
+
>
|
| 75 |
{bin.count}
|
| 76 |
</text>
|
| 77 |
)}
|
|
|
|
| 84 |
if (!isFirst && !isLast && idx % labelStep !== 0) return null;
|
| 85 |
const label = bin.binLabel.split("–")[0];
|
| 86 |
return (
|
| 87 |
+
<text
|
| 88 |
+
key={idx}
|
| 89 |
+
x={idx * (barWidth + gap) + barWidth / 2}
|
| 90 |
+
y={topPad + chartHeight + 14}
|
| 91 |
+
textAnchor="middle"
|
| 92 |
+
className="fill-slate-400"
|
| 93 |
+
fontSize={9}
|
| 94 |
+
>
|
| 95 |
{label}s
|
| 96 |
</text>
|
| 97 |
);
|
|
|
|
| 101 |
);
|
| 102 |
};
|
| 103 |
|
| 104 |
+
const Card: React.FC<{ label: string; value: string | number }> = ({
|
| 105 |
+
label,
|
| 106 |
+
value,
|
| 107 |
+
}) => (
|
| 108 |
<div className="bg-slate-800/60 rounded-lg p-4 border border-slate-700">
|
| 109 |
<p className="text-xs text-slate-400 uppercase tracking-wide">{label}</p>
|
| 110 |
<p className="text-xl font-bold tabular-nums mt-1">{value}</p>
|
|
|
|
| 121 |
return (
|
| 122 |
<div className="max-w-4xl mx-auto py-6 space-y-8">
|
| 123 |
<div>
|
| 124 |
+
<h2 className="text-xl text-slate-100">
|
| 125 |
+
<span className="font-bold">Dataset Statistics:</span>{" "}
|
| 126 |
+
<span className="font-normal text-slate-400">
|
| 127 |
+
{datasetInfo.repoId}
|
| 128 |
+
</span>
|
| 129 |
+
</h2>
|
| 130 |
</div>
|
| 131 |
|
| 132 |
{/* Overview cards */}
|
|
|
|
| 137 |
</div>
|
| 138 |
|
| 139 |
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
| 140 |
+
<Card
|
| 141 |
+
label="Total Frames"
|
| 142 |
+
value={datasetInfo.total_frames.toLocaleString()}
|
| 143 |
+
/>
|
| 144 |
+
<Card
|
| 145 |
+
label="Total Episodes"
|
| 146 |
+
value={datasetInfo.total_episodes.toLocaleString()}
|
| 147 |
+
/>
|
| 148 |
<Card label="FPS" value={datasetInfo.fps} />
|
| 149 |
+
<Card
|
| 150 |
+
label="Total Recording Time"
|
| 151 |
+
value={formatTotalTime(datasetInfo.total_frames, datasetInfo.fps)}
|
| 152 |
+
/>
|
| 153 |
</div>
|
| 154 |
|
| 155 |
{/* Camera resolutions */}
|
| 156 |
{datasetInfo.cameras.length > 0 && (
|
| 157 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 158 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-3">
|
| 159 |
+
Camera Resolutions
|
| 160 |
+
</h3>
|
| 161 |
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
|
| 162 |
{datasetInfo.cameras.map((cam: CameraInfo) => (
|
| 163 |
<div key={cam.name} className="bg-slate-900/50 rounded-md p-3">
|
| 164 |
+
<p
|
| 165 |
+
className="text-xs text-slate-400 mb-1 truncate"
|
| 166 |
+
title={cam.name}
|
| 167 |
+
>
|
| 168 |
+
{cam.name}
|
| 169 |
+
</p>
|
| 170 |
+
<p className="text-base font-bold tabular-nums">
|
| 171 |
+
{cam.width}×{cam.height}
|
| 172 |
+
</p>
|
| 173 |
</div>
|
| 174 |
))}
|
| 175 |
</div>
|
|
|
|
| 180 |
{loading && (
|
| 181 |
<div className="flex items-center gap-2 text-slate-400 text-sm py-4">
|
| 182 |
<svg className="animate-spin h-4 w-4" viewBox="0 0 24 24" fill="none">
|
| 183 |
+
<circle
|
| 184 |
+
className="opacity-25"
|
| 185 |
+
cx="12"
|
| 186 |
+
cy="12"
|
| 187 |
+
r="10"
|
| 188 |
+
stroke="currentColor"
|
| 189 |
+
strokeWidth="4"
|
| 190 |
+
/>
|
| 191 |
+
<path
|
| 192 |
+
className="opacity-75"
|
| 193 |
+
fill="currentColor"
|
| 194 |
+
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"
|
| 195 |
+
/>
|
| 196 |
</svg>
|
| 197 |
Computing episode statistics…
|
| 198 |
</div>
|
|
|
|
| 202 |
{els && (
|
| 203 |
<>
|
| 204 |
<div className="bg-slate-800/60 rounded-lg p-5 border border-slate-700">
|
| 205 |
+
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
| 206 |
+
Episode Lengths
|
| 207 |
+
</h3>
|
| 208 |
<div className="grid grid-cols-3 md:grid-cols-5 gap-4 mb-4">
|
| 209 |
+
<Card
|
| 210 |
+
label="Shortest"
|
| 211 |
+
value={`${els.shortestEpisodes[0]?.lengthSeconds ?? "–"}s`}
|
| 212 |
+
/>
|
| 213 |
+
<Card
|
| 214 |
+
label="Longest"
|
| 215 |
+
value={`${els.longestEpisodes[els.longestEpisodes.length - 1]?.lengthSeconds ?? "–"}s`}
|
| 216 |
+
/>
|
| 217 |
<Card label="Mean" value={`${els.meanEpisodeLength}s`} />
|
| 218 |
<Card label="Median" value={`${els.medianEpisodeLength}s`} />
|
| 219 |
<Card label="Std Dev" value={`${els.stdEpisodeLength}s`} />
|
|
|
|
| 225 |
<h3 className="text-sm font-semibold text-slate-200 mb-4">
|
| 226 |
Episode Length Distribution
|
| 227 |
<span className="text-xs text-slate-500 ml-2 font-normal">
|
| 228 |
+
{els.episodeLengthHistogram.length} bin
|
| 229 |
+
{els.episodeLengthHistogram.length !== 1 ? "s" : ""}
|
| 230 |
</span>
|
| 231 |
</h3>
|
| 232 |
<EpisodeLengthHistogram data={els.episodeLengthHistogram} />
|
| 233 |
</div>
|
| 234 |
)}
|
|
|
|
| 235 |
</>
|
| 236 |
)}
|
| 237 |
</div>
|
src/components/urdf-viewer.tsx
CHANGED
|
@@ -1,6 +1,12 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
| 5 |
import { OrbitControls, Grid, Html } from "@react-three/drei";
|
| 6 |
import * as THREE from "three";
|
|
@@ -50,23 +56,34 @@ function groupColumnsByPrefix(keys: string[]): Record<string, string[]> {
|
|
| 50 |
return groups;
|
| 51 |
}
|
| 52 |
|
| 53 |
-
function autoMatchJoints(
|
|
|
|
|
|
|
|
|
|
| 54 |
const mapping: Record<string, string> = {};
|
| 55 |
-
const suffixes = columnKeys.map((k) =>
|
|
|
|
|
|
|
| 56 |
|
| 57 |
for (const jointName of urdfJointNames) {
|
| 58 |
const lower = jointName.toLowerCase();
|
| 59 |
|
| 60 |
// Exact match on column suffix
|
| 61 |
const exactIdx = suffixes.findIndex((s) => s === lower);
|
| 62 |
-
if (exactIdx >= 0) {
|
|
|
|
|
|
|
|
|
|
| 63 |
|
| 64 |
// OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
|
| 65 |
const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
|
| 66 |
if (armMatch) {
|
| 67 |
const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
|
| 68 |
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 69 |
-
if (idx >= 0) {
|
|
|
|
|
|
|
|
|
|
| 70 |
}
|
| 71 |
|
| 72 |
// OpenArm: openarm_(left|right)_finger_joint1 → (left|right)_gripper
|
|
@@ -74,7 +91,10 @@ function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record
|
|
| 74 |
if (fingerMatch) {
|
| 75 |
const pattern = `${fingerMatch[1]}_gripper`;
|
| 76 |
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 77 |
-
if (idx >= 0) {
|
|
|
|
|
|
|
|
|
|
| 78 |
}
|
| 79 |
|
| 80 |
// finger_joint2 is a mimic joint — skip
|
|
@@ -87,7 +107,12 @@ function autoMatchJoints(urdfJointNames: string[], columnKeys: string[]): Record
|
|
| 87 |
return mapping;
|
| 88 |
}
|
| 89 |
|
| 90 |
-
const SINGLE_ARM_TIP_NAMES = [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
const DUAL_ARM_TIP_NAMES = ["openarm_left_hand_tcp", "openarm_right_hand_tcp"];
|
| 92 |
const TRAIL_DURATION = 1.0;
|
| 93 |
const TRAIL_COLORS = [new THREE.Color("#ff6600"), new THREE.Color("#00aaff")];
|
|
@@ -95,7 +120,12 @@ const MAX_TRAIL_POINTS = 300;
|
|
| 95 |
|
| 96 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 97 |
function RobotScene({
|
| 98 |
-
urdfUrl,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 99 |
}: {
|
| 100 |
urdfUrl: string;
|
| 101 |
jointValues: Record<string, number>;
|
|
@@ -110,7 +140,12 @@ function RobotScene({
|
|
| 110 |
const [loading, setLoading] = useState(true);
|
| 111 |
const [error, setError] = useState<string | null>(null);
|
| 112 |
|
| 113 |
-
type TrailState = {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
const trailsRef = useRef<TrailState[]>([]);
|
| 115 |
const linesRef = useRef<Line2[]>([]);
|
| 116 |
const trailMatsRef = useRef<LineMaterial[]>([]);
|
|
@@ -118,36 +153,56 @@ function RobotScene({
|
|
| 118 |
|
| 119 |
// Reset trails when episode changes
|
| 120 |
useEffect(() => {
|
| 121 |
-
for (const t of trailsRef.current) {
|
|
|
|
|
|
|
|
|
|
| 122 |
for (const l of linesRef.current) l.visible = false;
|
| 123 |
}, [trailResetKey]);
|
| 124 |
|
| 125 |
// Create/destroy trail Line2 objects when tip count changes
|
| 126 |
-
const ensureTrails = useCallback(
|
| 127 |
-
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
|
| 136 |
-
|
| 137 |
-
const
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
|
| 142 |
-
|
| 143 |
-
|
| 144 |
-
|
| 145 |
-
|
| 146 |
-
|
| 147 |
-
|
| 148 |
-
|
| 149 |
-
|
| 150 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 151 |
|
| 152 |
useEffect(() => {
|
| 153 |
setLoading(true);
|
|
@@ -159,22 +214,27 @@ function RobotScene({
|
|
| 159 |
// DAE (Collada) files — load with embedded materials
|
| 160 |
if (url.endsWith(".dae")) {
|
| 161 |
const colladaLoader = new ColladaLoader(mgr);
|
| 162 |
-
colladaLoader.load(
|
| 163 |
-
|
| 164 |
-
|
| 165 |
-
|
| 166 |
-
|
| 167 |
-
if (
|
| 168 |
-
|
| 169 |
-
|
| 170 |
-
mat.color
|
| 171 |
-
|
|
|
|
|
|
|
|
|
|
| 172 |
}
|
| 173 |
-
}
|
| 174 |
-
}
|
| 175 |
-
|
| 176 |
-
|
| 177 |
-
|
|
|
|
|
|
|
| 178 |
return;
|
| 179 |
}
|
| 180 |
// STL files — apply custom materials
|
|
@@ -186,12 +246,20 @@ function RobotScene({
|
|
| 186 |
let metalness = 0.1;
|
| 187 |
let roughness = 0.6;
|
| 188 |
if (url.includes("sts3215")) {
|
| 189 |
-
color = "#1a1a1a";
|
|
|
|
|
|
|
| 190 |
} else if (isOpenArm) {
|
| 191 |
color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
|
| 192 |
-
metalness = 0.15;
|
|
|
|
| 193 |
}
|
| 194 |
-
const material = new THREE.MeshStandardMaterial({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
onLoad(new THREE.Mesh(geometry, material));
|
| 196 |
},
|
| 197 |
undefined,
|
|
@@ -203,7 +271,9 @@ function RobotScene({
|
|
| 203 |
(robot) => {
|
| 204 |
robotRef.current = robot;
|
| 205 |
robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
| 206 |
-
robot.traverse((c) => {
|
|
|
|
|
|
|
| 207 |
robot.updateMatrixWorld(true);
|
| 208 |
robot.scale.set(scale, scale, scale);
|
| 209 |
scene.add(robot);
|
|
@@ -218,16 +288,28 @@ function RobotScene({
|
|
| 218 |
ensureTrails(tips.length);
|
| 219 |
|
| 220 |
const movable = Object.values(robot.joints)
|
| 221 |
-
.filter(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 222 |
.map((j) => j.name);
|
| 223 |
onJointsLoaded(movable);
|
| 224 |
setLoading(false);
|
| 225 |
},
|
| 226 |
undefined,
|
| 227 |
-
(err) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 228 |
);
|
| 229 |
return () => {
|
| 230 |
-
if (robotRef.current) {
|
|
|
|
|
|
|
|
|
|
| 231 |
tipLinksRef.current = [];
|
| 232 |
};
|
| 233 |
}, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails]);
|
|
@@ -281,9 +363,14 @@ function RobotScene({
|
|
| 281 |
trail.colors[i * 3 + 2] = trailColor.b * t;
|
| 282 |
}
|
| 283 |
|
| 284 |
-
if (trail.count < 2) {
|
|
|
|
|
|
|
|
|
|
| 285 |
const geo = new LineGeometry();
|
| 286 |
-
geo.setPositions(
|
|
|
|
|
|
|
| 287 |
geo.setColors(Array.from(trail.colors.subarray(0, trail.count * 3)));
|
| 288 |
line.geometry.dispose();
|
| 289 |
line.geometry = geo;
|
|
@@ -292,16 +379,32 @@ function RobotScene({
|
|
| 292 |
}
|
| 293 |
});
|
| 294 |
|
| 295 |
-
if (loading)
|
| 296 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 297 |
return null;
|
| 298 |
}
|
| 299 |
|
| 300 |
// ─── Playback ticker ───
|
| 301 |
function PlaybackDriver({
|
| 302 |
-
playing,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 303 |
}: {
|
| 304 |
-
playing: boolean;
|
|
|
|
|
|
|
| 305 |
frameRef: React.MutableRefObject<number>;
|
| 306 |
setFrame: React.Dispatch<React.SetStateAction<number>>;
|
| 307 |
}) {
|
|
@@ -347,7 +450,10 @@ export default function URDFViewer({
|
|
| 347 |
}) {
|
| 348 |
const { datasetInfo, episodes } = data;
|
| 349 |
const fps = datasetInfo.fps || 30;
|
| 350 |
-
const robotConfig = useMemo(
|
|
|
|
|
|
|
|
|
|
| 351 |
const { urdfUrl, scale } = robotConfig;
|
| 352 |
|
| 353 |
// Episode selection & chart data
|
|
@@ -358,33 +464,39 @@ export default function URDFViewer({
|
|
| 358 |
[data.episodeId]: data.flatChartData,
|
| 359 |
});
|
| 360 |
|
| 361 |
-
const handleEpisodeChange = useCallback(
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
| 366 |
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
|
| 372 |
-
|
| 373 |
-
|
| 374 |
-
|
| 375 |
-
|
| 376 |
-
|
| 377 |
-
|
| 378 |
-
|
| 379 |
-
|
| 380 |
-
|
| 381 |
-
|
|
|
|
|
|
|
| 382 |
|
| 383 |
const totalFrames = chartData.length;
|
| 384 |
|
| 385 |
// URDF joint names
|
| 386 |
const [urdfJointNames, setUrdfJointNames] = useState<string[]>([]);
|
| 387 |
-
const onJointsLoaded = useCallback(
|
|
|
|
|
|
|
|
|
|
| 388 |
|
| 389 |
// Feature groups
|
| 390 |
const columnGroups = useMemo(() => {
|
|
@@ -397,7 +509,8 @@ export default function URDFViewer({
|
|
| 397 |
() =>
|
| 398 |
groupNames.find((g) => g.toLowerCase().includes("state")) ??
|
| 399 |
groupNames.find((g) => g.toLowerCase().includes("action")) ??
|
| 400 |
-
groupNames[0] ??
|
|
|
|
| 401 |
[groupNames],
|
| 402 |
);
|
| 403 |
|
|
@@ -422,15 +535,19 @@ export default function URDFViewer({
|
|
| 422 |
const [playing, setPlaying] = useState(false);
|
| 423 |
const frameRef = useRef(0);
|
| 424 |
|
| 425 |
-
const handleFrameChange = useCallback(
|
| 426 |
-
|
| 427 |
-
|
| 428 |
-
|
| 429 |
-
|
|
|
|
|
|
|
|
|
|
| 430 |
|
| 431 |
// Filter out mimic joints (finger_joint2) from the UI list
|
| 432 |
const displayJointNames = useMemo(
|
| 433 |
-
() =>
|
|
|
|
| 434 |
[urdfJointNames],
|
| 435 |
);
|
| 436 |
|
|
@@ -441,10 +558,14 @@ export default function URDFViewer({
|
|
| 441 |
if (!jn.toLowerCase().includes("finger_joint1")) continue;
|
| 442 |
const col = mapping[jn];
|
| 443 |
if (!col) continue;
|
| 444 |
-
let min = Infinity,
|
|
|
|
| 445 |
for (const row of chartData) {
|
| 446 |
const v = row[col];
|
| 447 |
-
if (typeof v === "number") {
|
|
|
|
|
|
|
|
|
|
| 448 |
}
|
| 449 |
if (min < max) ranges[jn] = { min, max };
|
| 450 |
}
|
|
@@ -481,7 +602,9 @@ export default function URDFViewer({
|
|
| 481 |
}
|
| 482 |
|
| 483 |
const converted = detectAndConvert(revoluteValues);
|
| 484 |
-
revoluteNames.forEach((n, i) => {
|
|
|
|
|
|
|
| 485 |
|
| 486 |
// Copy finger_joint1 → finger_joint2 (mimic joints)
|
| 487 |
for (const jn of urdfJointNames) {
|
|
@@ -497,7 +620,11 @@ export default function URDFViewer({
|
|
| 497 |
const totalTime = (totalFrames / fps).toFixed(2);
|
| 498 |
|
| 499 |
if (data.flatChartData.length === 0) {
|
| 500 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
}
|
| 502 |
|
| 503 |
return (
|
|
@@ -506,22 +633,50 @@ export default function URDFViewer({
|
|
| 506 |
<div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700 relative">
|
| 507 |
{episodeLoading && (
|
| 508 |
<div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/70">
|
| 509 |
-
<span className="text-white text-lg animate-pulse">
|
|
|
|
|
|
|
| 510 |
</div>
|
| 511 |
)}
|
| 512 |
-
<Canvas
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 513 |
<ambientLight intensity={0.7} />
|
| 514 |
<directionalLight position={[3, 5, 4]} intensity={1.5} />
|
| 515 |
<directionalLight position={[-2, 3, -2]} intensity={0.6} />
|
| 516 |
<hemisphereLight args={["#b1e1ff", "#666666", 0.5]} />
|
| 517 |
-
<RobotScene
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 518 |
<Grid
|
| 519 |
-
args={[10, 10]}
|
| 520 |
-
|
| 521 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 522 |
/>
|
| 523 |
<OrbitControls target={[0, 0.8, 0]} />
|
| 524 |
-
<PlaybackDriver
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 525 |
</Canvas>
|
| 526 |
</div>
|
| 527 |
|
|
@@ -532,35 +687,55 @@ export default function URDFViewer({
|
|
| 532 |
{/* Episode selector */}
|
| 533 |
<div className="flex items-center gap-1.5 shrink-0">
|
| 534 |
<button
|
| 535 |
-
onClick={() => {
|
|
|
|
|
|
|
|
|
|
| 536 |
disabled={selectedEpisode <= episodes[0]}
|
| 537 |
className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
| 538 |
-
>
|
|
|
|
|
|
|
| 539 |
<select
|
| 540 |
value={selectedEpisode}
|
| 541 |
onChange={(e) => handleEpisodeChange(Number(e.target.value))}
|
| 542 |
className="bg-slate-900 text-slate-200 text-xs rounded px-1.5 py-1 border border-slate-600 w-28"
|
| 543 |
>
|
| 544 |
{episodes.map((ep) => (
|
| 545 |
-
<option key={ep} value={ep}>
|
|
|
|
|
|
|
| 546 |
))}
|
| 547 |
</select>
|
| 548 |
<button
|
| 549 |
-
onClick={() => {
|
|
|
|
|
|
|
|
|
|
| 550 |
disabled={selectedEpisode >= episodes[episodes.length - 1]}
|
| 551 |
className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
| 552 |
-
>
|
|
|
|
|
|
|
| 553 |
</div>
|
| 554 |
|
| 555 |
{/* Play/Pause */}
|
| 556 |
<button
|
| 557 |
-
onClick={() => {
|
|
|
|
|
|
|
|
|
|
| 558 |
className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
|
| 559 |
>
|
| 560 |
{playing ? (
|
| 561 |
-
<svg width="12" height="14" viewBox="0 0 12 14">
|
|
|
|
|
|
|
|
|
|
| 562 |
) : (
|
| 563 |
-
<svg width="12" height="14" viewBox="0 0 12 14">
|
|
|
|
|
|
|
| 564 |
)}
|
| 565 |
</button>
|
| 566 |
|
|
@@ -568,16 +743,30 @@ export default function URDFViewer({
|
|
| 568 |
<button
|
| 569 |
onClick={() => setTrailEnabled((v) => !v)}
|
| 570 |
className={`px-2 h-8 text-xs rounded transition-colors shrink-0 ${
|
| 571 |
-
trailEnabled
|
|
|
|
|
|
|
| 572 |
}`}
|
| 573 |
title={trailEnabled ? "Hide trail" : "Show trail"}
|
| 574 |
-
>
|
|
|
|
|
|
|
| 575 |
|
| 576 |
{/* Scrubber */}
|
| 577 |
-
<input
|
| 578 |
-
|
| 579 |
-
|
| 580 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 581 |
</div>
|
| 582 |
|
| 583 |
{/* Collapsible joint mapping */}
|
|
@@ -585,9 +774,16 @@ export default function URDFViewer({
|
|
| 585 |
onClick={() => setShowMapping((v) => !v)}
|
| 586 |
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
| 587 |
>
|
| 588 |
-
<span
|
|
|
|
|
|
|
|
|
|
|
|
|
| 589 |
Joint Mapping
|
| 590 |
-
<span className="text-slate-600">
|
|
|
|
|
|
|
|
|
|
| 591 |
</button>
|
| 592 |
|
| 593 |
{showMapping && (
|
|
@@ -596,10 +792,17 @@ export default function URDFViewer({
|
|
| 596 |
<label className="text-xs text-slate-400">Data source</label>
|
| 597 |
<div className="flex gap-1 flex-wrap">
|
| 598 |
{groupNames.map((name) => (
|
| 599 |
-
<button
|
|
|
|
|
|
|
| 600 |
className={`px-2 py-1 text-xs rounded transition-colors ${
|
| 601 |
-
selectedGroup === name
|
| 602 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 603 |
))}
|
| 604 |
</div>
|
| 605 |
</div>
|
|
@@ -610,28 +813,48 @@ export default function URDFViewer({
|
|
| 610 |
<tr className="text-slate-500">
|
| 611 |
<th className="text-left font-normal px-1">URDF Joint</th>
|
| 612 |
<th className="text-left font-normal px-1">→</th>
|
| 613 |
-
<th className="text-left font-normal px-1">
|
|
|
|
|
|
|
| 614 |
<th className="text-right font-normal px-1">Value</th>
|
| 615 |
</tr>
|
| 616 |
</thead>
|
| 617 |
<tbody>
|
| 618 |
{displayJointNames.map((jointName) => (
|
| 619 |
-
<tr
|
| 620 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 621 |
<td className="px-1 text-slate-600">→</td>
|
| 622 |
<td className="px-1 py-0.5">
|
| 623 |
-
<select
|
| 624 |
-
|
| 625 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 626 |
<option value="">-- unmapped --</option>
|
| 627 |
{selectedColumns.map((col) => {
|
| 628 |
const label = col.split(SERIES_DELIM).pop() ?? col;
|
| 629 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
})}
|
| 631 |
</select>
|
| 632 |
</td>
|
| 633 |
<td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
|
| 634 |
-
{jointValues[jointName] !== undefined
|
|
|
|
|
|
|
| 635 |
</td>
|
| 636 |
</tr>
|
| 637 |
))}
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, {
|
| 4 |
+
useState,
|
| 5 |
+
useEffect,
|
| 6 |
+
useRef,
|
| 7 |
+
useMemo,
|
| 8 |
+
useCallback,
|
| 9 |
+
} from "react";
|
| 10 |
import { Canvas, useThree, useFrame } from "@react-three/fiber";
|
| 11 |
import { OrbitControls, Grid, Html } from "@react-three/drei";
|
| 12 |
import * as THREE from "three";
|
|
|
|
| 56 |
return groups;
|
| 57 |
}
|
| 58 |
|
| 59 |
+
function autoMatchJoints(
|
| 60 |
+
urdfJointNames: string[],
|
| 61 |
+
columnKeys: string[],
|
| 62 |
+
): Record<string, string> {
|
| 63 |
const mapping: Record<string, string> = {};
|
| 64 |
+
const suffixes = columnKeys.map((k) =>
|
| 65 |
+
(k.split(SERIES_DELIM).pop()?.trim() ?? k).toLowerCase(),
|
| 66 |
+
);
|
| 67 |
|
| 68 |
for (const jointName of urdfJointNames) {
|
| 69 |
const lower = jointName.toLowerCase();
|
| 70 |
|
| 71 |
// Exact match on column suffix
|
| 72 |
const exactIdx = suffixes.findIndex((s) => s === lower);
|
| 73 |
+
if (exactIdx >= 0) {
|
| 74 |
+
mapping[jointName] = columnKeys[exactIdx];
|
| 75 |
+
continue;
|
| 76 |
+
}
|
| 77 |
|
| 78 |
// OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
|
| 79 |
const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
|
| 80 |
if (armMatch) {
|
| 81 |
const pattern = `${armMatch[1]}_joint_${armMatch[2]}`;
|
| 82 |
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 83 |
+
if (idx >= 0) {
|
| 84 |
+
mapping[jointName] = columnKeys[idx];
|
| 85 |
+
continue;
|
| 86 |
+
}
|
| 87 |
}
|
| 88 |
|
| 89 |
// OpenArm: openarm_(left|right)_finger_joint1 → (left|right)_gripper
|
|
|
|
| 91 |
if (fingerMatch) {
|
| 92 |
const pattern = `${fingerMatch[1]}_gripper`;
|
| 93 |
const idx = suffixes.findIndex((s) => s.includes(pattern));
|
| 94 |
+
if (idx >= 0) {
|
| 95 |
+
mapping[jointName] = columnKeys[idx];
|
| 96 |
+
continue;
|
| 97 |
+
}
|
| 98 |
}
|
| 99 |
|
| 100 |
// finger_joint2 is a mimic joint — skip
|
|
|
|
| 107 |
return mapping;
|
| 108 |
}
|
| 109 |
|
| 110 |
+
const SINGLE_ARM_TIP_NAMES = [
|
| 111 |
+
"gripper_frame_link",
|
| 112 |
+
"gripperframe",
|
| 113 |
+
"gripper_link",
|
| 114 |
+
"gripper",
|
| 115 |
+
];
|
| 116 |
const DUAL_ARM_TIP_NAMES = ["openarm_left_hand_tcp", "openarm_right_hand_tcp"];
|
| 117 |
const TRAIL_DURATION = 1.0;
|
| 118 |
const TRAIL_COLORS = [new THREE.Color("#ff6600"), new THREE.Color("#00aaff")];
|
|
|
|
| 120 |
|
| 121 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 122 |
function RobotScene({
|
| 123 |
+
urdfUrl,
|
| 124 |
+
jointValues,
|
| 125 |
+
onJointsLoaded,
|
| 126 |
+
trailEnabled,
|
| 127 |
+
trailResetKey,
|
| 128 |
+
scale,
|
| 129 |
}: {
|
| 130 |
urdfUrl: string;
|
| 131 |
jointValues: Record<string, number>;
|
|
|
|
| 140 |
const [loading, setLoading] = useState(true);
|
| 141 |
const [error, setError] = useState<string | null>(null);
|
| 142 |
|
| 143 |
+
type TrailState = {
|
| 144 |
+
positions: Float32Array;
|
| 145 |
+
colors: Float32Array;
|
| 146 |
+
times: number[];
|
| 147 |
+
count: number;
|
| 148 |
+
};
|
| 149 |
const trailsRef = useRef<TrailState[]>([]);
|
| 150 |
const linesRef = useRef<Line2[]>([]);
|
| 151 |
const trailMatsRef = useRef<LineMaterial[]>([]);
|
|
|
|
| 153 |
|
| 154 |
// Reset trails when episode changes
|
| 155 |
useEffect(() => {
|
| 156 |
+
for (const t of trailsRef.current) {
|
| 157 |
+
t.count = 0;
|
| 158 |
+
t.times = [];
|
| 159 |
+
}
|
| 160 |
for (const l of linesRef.current) l.visible = false;
|
| 161 |
}, [trailResetKey]);
|
| 162 |
|
| 163 |
// Create/destroy trail Line2 objects when tip count changes
|
| 164 |
+
const ensureTrails = useCallback(
|
| 165 |
+
(count: number) => {
|
| 166 |
+
if (trailCountRef.current === count) return;
|
| 167 |
+
// Remove old
|
| 168 |
+
for (const l of linesRef.current) {
|
| 169 |
+
scene.remove(l);
|
| 170 |
+
l.geometry.dispose();
|
| 171 |
+
}
|
| 172 |
+
for (const m of trailMatsRef.current) m.dispose();
|
| 173 |
+
// Create new
|
| 174 |
+
const trails: TrailState[] = [];
|
| 175 |
+
const lines: Line2[] = [];
|
| 176 |
+
const mats: LineMaterial[] = [];
|
| 177 |
+
for (let i = 0; i < count; i++) {
|
| 178 |
+
trails.push({
|
| 179 |
+
positions: new Float32Array(MAX_TRAIL_POINTS * 3),
|
| 180 |
+
colors: new Float32Array(MAX_TRAIL_POINTS * 3),
|
| 181 |
+
times: [],
|
| 182 |
+
count: 0,
|
| 183 |
+
});
|
| 184 |
+
const mat = new LineMaterial({
|
| 185 |
+
color: 0xffffff,
|
| 186 |
+
linewidth: 4,
|
| 187 |
+
vertexColors: true,
|
| 188 |
+
transparent: true,
|
| 189 |
+
worldUnits: false,
|
| 190 |
+
});
|
| 191 |
+
mat.resolution.set(window.innerWidth, window.innerHeight);
|
| 192 |
+
mats.push(mat);
|
| 193 |
+
const line = new Line2(new LineGeometry(), mat);
|
| 194 |
+
line.frustumCulled = false;
|
| 195 |
+
line.visible = false;
|
| 196 |
+
lines.push(line);
|
| 197 |
+
scene.add(line);
|
| 198 |
+
}
|
| 199 |
+
trailsRef.current = trails;
|
| 200 |
+
linesRef.current = lines;
|
| 201 |
+
trailMatsRef.current = mats;
|
| 202 |
+
trailCountRef.current = count;
|
| 203 |
+
},
|
| 204 |
+
[scene],
|
| 205 |
+
);
|
| 206 |
|
| 207 |
useEffect(() => {
|
| 208 |
setLoading(true);
|
|
|
|
| 214 |
// DAE (Collada) files — load with embedded materials
|
| 215 |
if (url.endsWith(".dae")) {
|
| 216 |
const colladaLoader = new ColladaLoader(mgr);
|
| 217 |
+
colladaLoader.load(
|
| 218 |
+
url,
|
| 219 |
+
(collada) => {
|
| 220 |
+
if (isOpenArm) {
|
| 221 |
+
collada.scene.traverse((child) => {
|
| 222 |
+
if (child instanceof THREE.Mesh && child.material) {
|
| 223 |
+
const mat = child.material as THREE.MeshStandardMaterial;
|
| 224 |
+
if (mat.side !== undefined) mat.side = THREE.DoubleSide;
|
| 225 |
+
if (mat.color) {
|
| 226 |
+
const hsl = { h: 0, s: 0, l: 0 };
|
| 227 |
+
mat.color.getHSL(hsl);
|
| 228 |
+
if (hsl.l > 0.7) mat.color.setHSL(hsl.h, hsl.s, 0.55);
|
| 229 |
+
}
|
| 230 |
}
|
| 231 |
+
});
|
| 232 |
+
}
|
| 233 |
+
onLoad(collada.scene);
|
| 234 |
+
},
|
| 235 |
+
undefined,
|
| 236 |
+
(err) => onLoad(new THREE.Object3D(), err as Error),
|
| 237 |
+
);
|
| 238 |
return;
|
| 239 |
}
|
| 240 |
// STL files — apply custom materials
|
|
|
|
| 246 |
let metalness = 0.1;
|
| 247 |
let roughness = 0.6;
|
| 248 |
if (url.includes("sts3215")) {
|
| 249 |
+
color = "#1a1a1a";
|
| 250 |
+
metalness = 0.7;
|
| 251 |
+
roughness = 0.3;
|
| 252 |
} else if (isOpenArm) {
|
| 253 |
color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
|
| 254 |
+
metalness = 0.15;
|
| 255 |
+
roughness = 0.6;
|
| 256 |
}
|
| 257 |
+
const material = new THREE.MeshStandardMaterial({
|
| 258 |
+
color,
|
| 259 |
+
metalness,
|
| 260 |
+
roughness,
|
| 261 |
+
side: isOpenArm ? THREE.DoubleSide : THREE.FrontSide,
|
| 262 |
+
});
|
| 263 |
onLoad(new THREE.Mesh(geometry, material));
|
| 264 |
},
|
| 265 |
undefined,
|
|
|
|
| 271 |
(robot) => {
|
| 272 |
robotRef.current = robot;
|
| 273 |
robot.rotateOnAxis(new THREE.Vector3(1, 0, 0), -Math.PI / 2);
|
| 274 |
+
robot.traverse((c) => {
|
| 275 |
+
c.castShadow = true;
|
| 276 |
+
});
|
| 277 |
robot.updateMatrixWorld(true);
|
| 278 |
robot.scale.set(scale, scale, scale);
|
| 279 |
scene.add(robot);
|
|
|
|
| 288 |
ensureTrails(tips.length);
|
| 289 |
|
| 290 |
const movable = Object.values(robot.joints)
|
| 291 |
+
.filter(
|
| 292 |
+
(j) =>
|
| 293 |
+
j.jointType === "revolute" ||
|
| 294 |
+
j.jointType === "continuous" ||
|
| 295 |
+
j.jointType === "prismatic",
|
| 296 |
+
)
|
| 297 |
.map((j) => j.name);
|
| 298 |
onJointsLoaded(movable);
|
| 299 |
setLoading(false);
|
| 300 |
},
|
| 301 |
undefined,
|
| 302 |
+
(err) => {
|
| 303 |
+
console.error("Error loading URDF:", err);
|
| 304 |
+
setError(String(err));
|
| 305 |
+
setLoading(false);
|
| 306 |
+
},
|
| 307 |
);
|
| 308 |
return () => {
|
| 309 |
+
if (robotRef.current) {
|
| 310 |
+
scene.remove(robotRef.current);
|
| 311 |
+
robotRef.current = null;
|
| 312 |
+
}
|
| 313 |
tipLinksRef.current = [];
|
| 314 |
};
|
| 315 |
}, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails]);
|
|
|
|
| 363 |
trail.colors[i * 3 + 2] = trailColor.b * t;
|
| 364 |
}
|
| 365 |
|
| 366 |
+
if (trail.count < 2) {
|
| 367 |
+
line.visible = false;
|
| 368 |
+
continue;
|
| 369 |
+
}
|
| 370 |
const geo = new LineGeometry();
|
| 371 |
+
geo.setPositions(
|
| 372 |
+
Array.from(trail.positions.subarray(0, trail.count * 3)),
|
| 373 |
+
);
|
| 374 |
geo.setColors(Array.from(trail.colors.subarray(0, trail.count * 3)));
|
| 375 |
line.geometry.dispose();
|
| 376 |
line.geometry = geo;
|
|
|
|
| 379 |
}
|
| 380 |
});
|
| 381 |
|
| 382 |
+
if (loading)
|
| 383 |
+
return (
|
| 384 |
+
<Html center>
|
| 385 |
+
<span className="text-white text-lg">Loading robot…</span>
|
| 386 |
+
</Html>
|
| 387 |
+
);
|
| 388 |
+
if (error)
|
| 389 |
+
return (
|
| 390 |
+
<Html center>
|
| 391 |
+
<span className="text-red-400">Failed to load URDF</span>
|
| 392 |
+
</Html>
|
| 393 |
+
);
|
| 394 |
return null;
|
| 395 |
}
|
| 396 |
|
| 397 |
// ─── Playback ticker ───
|
| 398 |
function PlaybackDriver({
|
| 399 |
+
playing,
|
| 400 |
+
fps,
|
| 401 |
+
totalFrames,
|
| 402 |
+
frameRef,
|
| 403 |
+
setFrame,
|
| 404 |
}: {
|
| 405 |
+
playing: boolean;
|
| 406 |
+
fps: number;
|
| 407 |
+
totalFrames: number;
|
| 408 |
frameRef: React.MutableRefObject<number>;
|
| 409 |
setFrame: React.Dispatch<React.SetStateAction<number>>;
|
| 410 |
}) {
|
|
|
|
| 450 |
}) {
|
| 451 |
const { datasetInfo, episodes } = data;
|
| 452 |
const fps = datasetInfo.fps || 30;
|
| 453 |
+
const robotConfig = useMemo(
|
| 454 |
+
() => getRobotConfig(datasetInfo.robot_type),
|
| 455 |
+
[datasetInfo.robot_type],
|
| 456 |
+
);
|
| 457 |
const { urdfUrl, scale } = robotConfig;
|
| 458 |
|
| 459 |
// Episode selection & chart data
|
|
|
|
| 464 |
[data.episodeId]: data.flatChartData,
|
| 465 |
});
|
| 466 |
|
| 467 |
+
const handleEpisodeChange = useCallback(
|
| 468 |
+
(epId: number) => {
|
| 469 |
+
setSelectedEpisode(epId);
|
| 470 |
+
setFrame(0);
|
| 471 |
+
frameRef.current = 0;
|
| 472 |
+
setPlaying(false);
|
| 473 |
|
| 474 |
+
if (chartDataCache.current[epId]) {
|
| 475 |
+
setChartData(chartDataCache.current[epId]);
|
| 476 |
+
return;
|
| 477 |
+
}
|
| 478 |
|
| 479 |
+
if (!org || !dataset) return;
|
| 480 |
+
setEpisodeLoading(true);
|
| 481 |
+
fetchEpisodeChartData(org, dataset, epId)
|
| 482 |
+
.then((result) => {
|
| 483 |
+
chartDataCache.current[epId] = result;
|
| 484 |
+
setChartData(result);
|
| 485 |
+
})
|
| 486 |
+
.catch((err) => console.error("Failed to load episode:", err))
|
| 487 |
+
.finally(() => setEpisodeLoading(false));
|
| 488 |
+
},
|
| 489 |
+
[org, dataset],
|
| 490 |
+
);
|
| 491 |
|
| 492 |
const totalFrames = chartData.length;
|
| 493 |
|
| 494 |
// URDF joint names
|
| 495 |
const [urdfJointNames, setUrdfJointNames] = useState<string[]>([]);
|
| 496 |
+
const onJointsLoaded = useCallback(
|
| 497 |
+
(names: string[]) => setUrdfJointNames(names),
|
| 498 |
+
[],
|
| 499 |
+
);
|
| 500 |
|
| 501 |
// Feature groups
|
| 502 |
const columnGroups = useMemo(() => {
|
|
|
|
| 509 |
() =>
|
| 510 |
groupNames.find((g) => g.toLowerCase().includes("state")) ??
|
| 511 |
groupNames.find((g) => g.toLowerCase().includes("action")) ??
|
| 512 |
+
groupNames[0] ??
|
| 513 |
+
"",
|
| 514 |
[groupNames],
|
| 515 |
);
|
| 516 |
|
|
|
|
| 535 |
const [playing, setPlaying] = useState(false);
|
| 536 |
const frameRef = useRef(0);
|
| 537 |
|
| 538 |
+
const handleFrameChange = useCallback(
|
| 539 |
+
(e: React.ChangeEvent<HTMLInputElement>) => {
|
| 540 |
+
const f = parseInt(e.target.value);
|
| 541 |
+
setFrame(f);
|
| 542 |
+
frameRef.current = f;
|
| 543 |
+
},
|
| 544 |
+
[],
|
| 545 |
+
);
|
| 546 |
|
| 547 |
// Filter out mimic joints (finger_joint2) from the UI list
|
| 548 |
const displayJointNames = useMemo(
|
| 549 |
+
() =>
|
| 550 |
+
urdfJointNames.filter((n) => !n.toLowerCase().includes("finger_joint2")),
|
| 551 |
[urdfJointNames],
|
| 552 |
);
|
| 553 |
|
|
|
|
| 558 |
if (!jn.toLowerCase().includes("finger_joint1")) continue;
|
| 559 |
const col = mapping[jn];
|
| 560 |
if (!col) continue;
|
| 561 |
+
let min = Infinity,
|
| 562 |
+
max = -Infinity;
|
| 563 |
for (const row of chartData) {
|
| 564 |
const v = row[col];
|
| 565 |
+
if (typeof v === "number") {
|
| 566 |
+
if (v < min) min = v;
|
| 567 |
+
if (v > max) max = v;
|
| 568 |
+
}
|
| 569 |
}
|
| 570 |
if (min < max) ranges[jn] = { min, max };
|
| 571 |
}
|
|
|
|
| 602 |
}
|
| 603 |
|
| 604 |
const converted = detectAndConvert(revoluteValues);
|
| 605 |
+
revoluteNames.forEach((n, i) => {
|
| 606 |
+
values[n] = converted[i];
|
| 607 |
+
});
|
| 608 |
|
| 609 |
// Copy finger_joint1 → finger_joint2 (mimic joints)
|
| 610 |
for (const jn of urdfJointNames) {
|
|
|
|
| 620 |
const totalTime = (totalFrames / fps).toFixed(2);
|
| 621 |
|
| 622 |
if (data.flatChartData.length === 0) {
|
| 623 |
+
return (
|
| 624 |
+
<div className="text-slate-400 p-8 text-center">
|
| 625 |
+
No trajectory data available.
|
| 626 |
+
</div>
|
| 627 |
+
);
|
| 628 |
}
|
| 629 |
|
| 630 |
return (
|
|
|
|
| 633 |
<div className="flex-1 min-h-0 bg-slate-950 rounded-lg overflow-hidden border border-slate-700 relative">
|
| 634 |
{episodeLoading && (
|
| 635 |
<div className="absolute inset-0 z-10 flex items-center justify-center bg-slate-950/70">
|
| 636 |
+
<span className="text-white text-lg animate-pulse">
|
| 637 |
+
Loading episode {selectedEpisode}…
|
| 638 |
+
</span>
|
| 639 |
</div>
|
| 640 |
)}
|
| 641 |
+
<Canvas
|
| 642 |
+
camera={{
|
| 643 |
+
position: [0.3 * scale, 0.25 * scale, 0.3 * scale],
|
| 644 |
+
fov: 45,
|
| 645 |
+
near: 0.01,
|
| 646 |
+
far: 100,
|
| 647 |
+
}}
|
| 648 |
+
>
|
| 649 |
<ambientLight intensity={0.7} />
|
| 650 |
<directionalLight position={[3, 5, 4]} intensity={1.5} />
|
| 651 |
<directionalLight position={[-2, 3, -2]} intensity={0.6} />
|
| 652 |
<hemisphereLight args={["#b1e1ff", "#666666", 0.5]} />
|
| 653 |
+
<RobotScene
|
| 654 |
+
urdfUrl={urdfUrl}
|
| 655 |
+
jointValues={jointValues}
|
| 656 |
+
onJointsLoaded={onJointsLoaded}
|
| 657 |
+
trailEnabled={trailEnabled}
|
| 658 |
+
trailResetKey={selectedEpisode}
|
| 659 |
+
scale={scale}
|
| 660 |
+
/>
|
| 661 |
<Grid
|
| 662 |
+
args={[10, 10]}
|
| 663 |
+
cellSize={0.2}
|
| 664 |
+
cellThickness={0.5}
|
| 665 |
+
cellColor="#334155"
|
| 666 |
+
sectionSize={1}
|
| 667 |
+
sectionThickness={1}
|
| 668 |
+
sectionColor="#475569"
|
| 669 |
+
fadeDistance={10}
|
| 670 |
+
position={[0, 0, 0]}
|
| 671 |
/>
|
| 672 |
<OrbitControls target={[0, 0.8, 0]} />
|
| 673 |
+
<PlaybackDriver
|
| 674 |
+
playing={playing}
|
| 675 |
+
fps={fps}
|
| 676 |
+
totalFrames={totalFrames}
|
| 677 |
+
frameRef={frameRef}
|
| 678 |
+
setFrame={setFrame}
|
| 679 |
+
/>
|
| 680 |
</Canvas>
|
| 681 |
</div>
|
| 682 |
|
|
|
|
| 687 |
{/* Episode selector */}
|
| 688 |
<div className="flex items-center gap-1.5 shrink-0">
|
| 689 |
<button
|
| 690 |
+
onClick={() => {
|
| 691 |
+
if (selectedEpisode > episodes[0])
|
| 692 |
+
handleEpisodeChange(selectedEpisode - 1);
|
| 693 |
+
}}
|
| 694 |
disabled={selectedEpisode <= episodes[0]}
|
| 695 |
className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
| 696 |
+
>
|
| 697 |
+
◀
|
| 698 |
+
</button>
|
| 699 |
<select
|
| 700 |
value={selectedEpisode}
|
| 701 |
onChange={(e) => handleEpisodeChange(Number(e.target.value))}
|
| 702 |
className="bg-slate-900 text-slate-200 text-xs rounded px-1.5 py-1 border border-slate-600 w-28"
|
| 703 |
>
|
| 704 |
{episodes.map((ep) => (
|
| 705 |
+
<option key={ep} value={ep}>
|
| 706 |
+
Episode {ep}
|
| 707 |
+
</option>
|
| 708 |
))}
|
| 709 |
</select>
|
| 710 |
<button
|
| 711 |
+
onClick={() => {
|
| 712 |
+
if (selectedEpisode < episodes[episodes.length - 1])
|
| 713 |
+
handleEpisodeChange(selectedEpisode + 1);
|
| 714 |
+
}}
|
| 715 |
disabled={selectedEpisode >= episodes[episodes.length - 1]}
|
| 716 |
className="w-6 h-6 flex items-center justify-center rounded bg-slate-700 hover:bg-slate-600 text-slate-300 disabled:opacity-30 disabled:cursor-not-allowed text-xs"
|
| 717 |
+
>
|
| 718 |
+
▶
|
| 719 |
+
</button>
|
| 720 |
</div>
|
| 721 |
|
| 722 |
{/* Play/Pause */}
|
| 723 |
<button
|
| 724 |
+
onClick={() => {
|
| 725 |
+
setPlaying(!playing);
|
| 726 |
+
if (!playing) frameRef.current = frame;
|
| 727 |
+
}}
|
| 728 |
className="w-8 h-8 flex items-center justify-center rounded bg-orange-600 hover:bg-orange-500 text-white transition-colors shrink-0"
|
| 729 |
>
|
| 730 |
{playing ? (
|
| 731 |
+
<svg width="12" height="14" viewBox="0 0 12 14">
|
| 732 |
+
<rect x="1" y="1" width="3" height="12" fill="white" />
|
| 733 |
+
<rect x="8" y="1" width="3" height="12" fill="white" />
|
| 734 |
+
</svg>
|
| 735 |
) : (
|
| 736 |
+
<svg width="12" height="14" viewBox="0 0 12 14">
|
| 737 |
+
<polygon points="2,1 11,7 2,13" fill="white" />
|
| 738 |
+
</svg>
|
| 739 |
)}
|
| 740 |
</button>
|
| 741 |
|
|
|
|
| 743 |
<button
|
| 744 |
onClick={() => setTrailEnabled((v) => !v)}
|
| 745 |
className={`px-2 h-8 text-xs rounded transition-colors shrink-0 ${
|
| 746 |
+
trailEnabled
|
| 747 |
+
? "bg-orange-600/30 text-orange-400 border border-orange-500"
|
| 748 |
+
: "bg-slate-700 text-slate-400 border border-slate-600"
|
| 749 |
}`}
|
| 750 |
title={trailEnabled ? "Hide trail" : "Show trail"}
|
| 751 |
+
>
|
| 752 |
+
Trail
|
| 753 |
+
</button>
|
| 754 |
|
| 755 |
{/* Scrubber */}
|
| 756 |
+
<input
|
| 757 |
+
type="range"
|
| 758 |
+
min={0}
|
| 759 |
+
max={Math.max(totalFrames - 1, 0)}
|
| 760 |
+
value={frame}
|
| 761 |
+
onChange={handleFrameChange}
|
| 762 |
+
className="flex-1 h-1.5 accent-orange-500 cursor-pointer"
|
| 763 |
+
/>
|
| 764 |
+
<span className="text-xs text-slate-400 tabular-nums w-28 text-right shrink-0">
|
| 765 |
+
{currentTime}s / {totalTime}s
|
| 766 |
+
</span>
|
| 767 |
+
<span className="text-xs text-slate-500 tabular-nums w-20 text-right shrink-0">
|
| 768 |
+
F {frame}/{Math.max(totalFrames - 1, 0)}
|
| 769 |
+
</span>
|
| 770 |
</div>
|
| 771 |
|
| 772 |
{/* Collapsible joint mapping */}
|
|
|
|
| 774 |
onClick={() => setShowMapping((v) => !v)}
|
| 775 |
className="flex items-center gap-1.5 text-xs text-slate-400 hover:text-slate-200 transition-colors"
|
| 776 |
>
|
| 777 |
+
<span
|
| 778 |
+
className={`transition-transform ${showMapping ? "rotate-90" : ""}`}
|
| 779 |
+
>
|
| 780 |
+
▶
|
| 781 |
+
</span>
|
| 782 |
Joint Mapping
|
| 783 |
+
<span className="text-slate-600">
|
| 784 |
+
({Object.keys(mapping).filter((k) => mapping[k]).length}/
|
| 785 |
+
{displayJointNames.length} mapped)
|
| 786 |
+
</span>
|
| 787 |
</button>
|
| 788 |
|
| 789 |
{showMapping && (
|
|
|
|
| 792 |
<label className="text-xs text-slate-400">Data source</label>
|
| 793 |
<div className="flex gap-1 flex-wrap">
|
| 794 |
{groupNames.map((name) => (
|
| 795 |
+
<button
|
| 796 |
+
key={name}
|
| 797 |
+
onClick={() => setSelectedGroup(name)}
|
| 798 |
className={`px-2 py-1 text-xs rounded transition-colors ${
|
| 799 |
+
selectedGroup === name
|
| 800 |
+
? "bg-orange-600 text-white"
|
| 801 |
+
: "bg-slate-700 text-slate-300 hover:bg-slate-600"
|
| 802 |
+
}`}
|
| 803 |
+
>
|
| 804 |
+
{name}
|
| 805 |
+
</button>
|
| 806 |
))}
|
| 807 |
</div>
|
| 808 |
</div>
|
|
|
|
| 813 |
<tr className="text-slate-500">
|
| 814 |
<th className="text-left font-normal px-1">URDF Joint</th>
|
| 815 |
<th className="text-left font-normal px-1">→</th>
|
| 816 |
+
<th className="text-left font-normal px-1">
|
| 817 |
+
Dataset Column
|
| 818 |
+
</th>
|
| 819 |
<th className="text-right font-normal px-1">Value</th>
|
| 820 |
</tr>
|
| 821 |
</thead>
|
| 822 |
<tbody>
|
| 823 |
{displayJointNames.map((jointName) => (
|
| 824 |
+
<tr
|
| 825 |
+
key={jointName}
|
| 826 |
+
className="border-t border-slate-700/50"
|
| 827 |
+
>
|
| 828 |
+
<td className="px-1 py-0.5 text-slate-300 font-mono">
|
| 829 |
+
{jointName}
|
| 830 |
+
</td>
|
| 831 |
<td className="px-1 text-slate-600">→</td>
|
| 832 |
<td className="px-1 py-0.5">
|
| 833 |
+
<select
|
| 834 |
+
value={mapping[jointName] ?? ""}
|
| 835 |
+
onChange={(e) =>
|
| 836 |
+
setMapping((m) => ({
|
| 837 |
+
...m,
|
| 838 |
+
[jointName]: e.target.value,
|
| 839 |
+
}))
|
| 840 |
+
}
|
| 841 |
+
className="bg-slate-900 text-slate-200 text-xs rounded px-1 py-0.5 border border-slate-600 w-full max-w-[200px]"
|
| 842 |
+
>
|
| 843 |
<option value="">-- unmapped --</option>
|
| 844 |
{selectedColumns.map((col) => {
|
| 845 |
const label = col.split(SERIES_DELIM).pop() ?? col;
|
| 846 |
+
return (
|
| 847 |
+
<option key={col} value={col}>
|
| 848 |
+
{label}
|
| 849 |
+
</option>
|
| 850 |
+
);
|
| 851 |
})}
|
| 852 |
</select>
|
| 853 |
</td>
|
| 854 |
<td className="px-1 py-0.5 text-right tabular-nums text-slate-400 font-mono">
|
| 855 |
+
{jointValues[jointName] !== undefined
|
| 856 |
+
? jointValues[jointName].toFixed(3)
|
| 857 |
+
: "—"}
|
| 858 |
</td>
|
| 859 |
</tr>
|
| 860 |
))}
|
src/components/videos-player.tsx
CHANGED
|
@@ -149,7 +149,8 @@ export const VideosPlayer = ({
|
|
| 149 |
// For the primary video, only seek when the change came from an external source
|
| 150 |
// (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
|
| 151 |
useEffect(() => {
|
| 152 |
-
const isExternalSeek =
|
|
|
|
| 153 |
|
| 154 |
videoRefs.current.forEach((video, index) => {
|
| 155 |
if (!video) return;
|
|
@@ -176,7 +177,7 @@ export const VideosPlayer = ({
|
|
| 176 |
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
| 177 |
const video = e.target as HTMLVideoElement;
|
| 178 |
if (video && video.duration) {
|
| 179 |
-
const videoIndex = videoRefs.current.findIndex(ref => ref === video);
|
| 180 |
const videoInfo = videosInfo[videoIndex];
|
| 181 |
|
| 182 |
if (videoInfo?.isSegmented) {
|
|
@@ -220,11 +221,11 @@ export const VideosPlayer = ({
|
|
| 220 |
}
|
| 221 |
}
|
| 222 |
};
|
| 223 |
-
|
| 224 |
-
video.addEventListener(
|
| 225 |
-
|
| 226 |
videoCleanupHandlers.set(video, () => {
|
| 227 |
-
video.removeEventListener(
|
| 228 |
});
|
| 229 |
}
|
| 230 |
|
|
|
|
| 149 |
// For the primary video, only seek when the change came from an external source
|
| 150 |
// (slider drag, chart click, etc.) — detected by comparing against lastVideoTimeRef.
|
| 151 |
useEffect(() => {
|
| 152 |
+
const isExternalSeek =
|
| 153 |
+
Math.abs(currentTime - lastVideoTimeRef.current) > 0.3;
|
| 154 |
|
| 155 |
videoRefs.current.forEach((video, index) => {
|
| 156 |
if (!video) return;
|
|
|
|
| 177 |
const handleTimeUpdate = (e: React.SyntheticEvent<HTMLVideoElement>) => {
|
| 178 |
const video = e.target as HTMLVideoElement;
|
| 179 |
if (video && video.duration) {
|
| 180 |
+
const videoIndex = videoRefs.current.findIndex((ref) => ref === video);
|
| 181 |
const videoInfo = videosInfo[videoIndex];
|
| 182 |
|
| 183 |
if (videoInfo?.isSegmented) {
|
|
|
|
| 221 |
}
|
| 222 |
}
|
| 223 |
};
|
| 224 |
+
|
| 225 |
+
video.addEventListener("timeupdate", handleTimeUpdate);
|
| 226 |
+
|
| 227 |
videoCleanupHandlers.set(video, () => {
|
| 228 |
+
video.removeEventListener("timeupdate", handleTimeUpdate);
|
| 229 |
});
|
| 230 |
}
|
| 231 |
|
src/context/flagged-episodes-context.tsx
CHANGED
|
@@ -1,6 +1,13 @@
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
-
import React, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
|
| 5 |
const STORAGE_KEY = "flagged-episodes";
|
| 6 |
|
|
@@ -9,12 +16,18 @@ function loadFromStorage(): Set<number> {
|
|
| 9 |
try {
|
| 10 |
const raw = sessionStorage.getItem(STORAGE_KEY);
|
| 11 |
if (raw) return new Set(JSON.parse(raw) as number[]);
|
| 12 |
-
} catch {
|
|
|
|
|
|
|
| 13 |
return new Set();
|
| 14 |
}
|
| 15 |
|
| 16 |
function saveToStorage(s: Set<number>) {
|
| 17 |
-
try {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
|
| 20 |
type FlaggedEpisodesContextType = {
|
|
@@ -26,29 +39,39 @@ type FlaggedEpisodesContextType = {
|
|
| 26 |
clear: () => void;
|
| 27 |
};
|
| 28 |
|
| 29 |
-
const FlaggedEpisodesContext = createContext<
|
|
|
|
|
|
|
| 30 |
|
| 31 |
export function useFlaggedEpisodes() {
|
| 32 |
const ctx = useContext(FlaggedEpisodesContext);
|
| 33 |
-
if (!ctx)
|
|
|
|
|
|
|
|
|
|
| 34 |
return ctx;
|
| 35 |
}
|
| 36 |
|
| 37 |
-
export const FlaggedEpisodesProvider: React.FC<{
|
|
|
|
|
|
|
| 38 |
const [flagged, setFlagged] = useState<Set<number>>(() => loadFromStorage());
|
| 39 |
|
| 40 |
-
useEffect(() => {
|
|
|
|
|
|
|
| 41 |
|
| 42 |
const toggle = useCallback((id: number) => {
|
| 43 |
-
setFlagged(prev => {
|
| 44 |
const next = new Set(prev);
|
| 45 |
-
if (next.has(id)) next.delete(id);
|
|
|
|
| 46 |
return next;
|
| 47 |
});
|
| 48 |
}, []);
|
| 49 |
|
| 50 |
const addMany = useCallback((ids: number[]) => {
|
| 51 |
-
setFlagged(prev => {
|
| 52 |
const next = new Set(prev);
|
| 53 |
for (const id of ids) next.add(id);
|
| 54 |
return next;
|
|
@@ -59,9 +82,17 @@ export const FlaggedEpisodesProvider: React.FC<{ children: React.ReactNode }> =
|
|
| 59 |
|
| 60 |
const has = useCallback((id: number) => flagged.has(id), [flagged]);
|
| 61 |
|
| 62 |
-
const value = useMemo(
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
return (
|
| 67 |
<FlaggedEpisodesContext.Provider value={value}>
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import React, {
|
| 4 |
+
createContext,
|
| 5 |
+
useContext,
|
| 6 |
+
useState,
|
| 7 |
+
useCallback,
|
| 8 |
+
useMemo,
|
| 9 |
+
useEffect,
|
| 10 |
+
} from "react";
|
| 11 |
|
| 12 |
const STORAGE_KEY = "flagged-episodes";
|
| 13 |
|
|
|
|
| 16 |
try {
|
| 17 |
const raw = sessionStorage.getItem(STORAGE_KEY);
|
| 18 |
if (raw) return new Set(JSON.parse(raw) as number[]);
|
| 19 |
+
} catch {
|
| 20 |
+
/* ignore */
|
| 21 |
+
}
|
| 22 |
return new Set();
|
| 23 |
}
|
| 24 |
|
| 25 |
function saveToStorage(s: Set<number>) {
|
| 26 |
+
try {
|
| 27 |
+
sessionStorage.setItem(STORAGE_KEY, JSON.stringify([...s]));
|
| 28 |
+
} catch {
|
| 29 |
+
/* ignore */
|
| 30 |
+
}
|
| 31 |
}
|
| 32 |
|
| 33 |
type FlaggedEpisodesContextType = {
|
|
|
|
| 39 |
clear: () => void;
|
| 40 |
};
|
| 41 |
|
| 42 |
+
const FlaggedEpisodesContext = createContext<
|
| 43 |
+
FlaggedEpisodesContextType | undefined
|
| 44 |
+
>(undefined);
|
| 45 |
|
| 46 |
export function useFlaggedEpisodes() {
|
| 47 |
const ctx = useContext(FlaggedEpisodesContext);
|
| 48 |
+
if (!ctx)
|
| 49 |
+
throw new Error(
|
| 50 |
+
"useFlaggedEpisodes must be used within FlaggedEpisodesProvider",
|
| 51 |
+
);
|
| 52 |
return ctx;
|
| 53 |
}
|
| 54 |
|
| 55 |
+
export const FlaggedEpisodesProvider: React.FC<{
|
| 56 |
+
children: React.ReactNode;
|
| 57 |
+
}> = ({ children }) => {
|
| 58 |
const [flagged, setFlagged] = useState<Set<number>>(() => loadFromStorage());
|
| 59 |
|
| 60 |
+
useEffect(() => {
|
| 61 |
+
saveToStorage(flagged);
|
| 62 |
+
}, [flagged]);
|
| 63 |
|
| 64 |
const toggle = useCallback((id: number) => {
|
| 65 |
+
setFlagged((prev) => {
|
| 66 |
const next = new Set(prev);
|
| 67 |
+
if (next.has(id)) next.delete(id);
|
| 68 |
+
else next.add(id);
|
| 69 |
return next;
|
| 70 |
});
|
| 71 |
}, []);
|
| 72 |
|
| 73 |
const addMany = useCallback((ids: number[]) => {
|
| 74 |
+
setFlagged((prev) => {
|
| 75 |
const next = new Set(prev);
|
| 76 |
for (const id of ids) next.add(id);
|
| 77 |
return next;
|
|
|
|
| 82 |
|
| 83 |
const has = useCallback((id: number) => flagged.has(id), [flagged]);
|
| 84 |
|
| 85 |
+
const value = useMemo(
|
| 86 |
+
() => ({
|
| 87 |
+
flagged,
|
| 88 |
+
count: flagged.size,
|
| 89 |
+
has,
|
| 90 |
+
toggle,
|
| 91 |
+
addMany,
|
| 92 |
+
clear,
|
| 93 |
+
}),
|
| 94 |
+
[flagged, has, toggle, addMany, clear],
|
| 95 |
+
);
|
| 96 |
|
| 97 |
return (
|
| 98 |
<FlaggedEpisodesContext.Provider value={value}>
|
src/lib/so101-robot.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
| 1 |
export function isSO101Robot(robotType: string | null): boolean {
|
| 2 |
if (!robotType) return false;
|
| 3 |
const lower = robotType.toLowerCase();
|
| 4 |
-
return
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
}
|
| 6 |
|
| 7 |
export function isOpenArmRobot(robotType: string | null): boolean {
|
|
|
|
| 1 |
export function isSO101Robot(robotType: string | null): boolean {
|
| 2 |
if (!robotType) return false;
|
| 3 |
const lower = robotType.toLowerCase();
|
| 4 |
+
return (
|
| 5 |
+
lower.includes("so100") ||
|
| 6 |
+
lower.includes("so101") ||
|
| 7 |
+
lower === "so_follower"
|
| 8 |
+
);
|
| 9 |
}
|
| 10 |
|
| 11 |
export function isOpenArmRobot(robotType: string | null): boolean {
|
src/utils/versionUtils.ts
CHANGED
|
@@ -32,7 +32,10 @@ export interface DatasetInfo {
|
|
| 32 |
}
|
| 33 |
|
| 34 |
// In-memory cache for dataset info (5 min TTL)
|
| 35 |
-
const datasetInfoCache = new Map<
|
|
|
|
|
|
|
|
|
|
| 36 |
const CACHE_TTL_MS = 5 * 60 * 1000;
|
| 37 |
|
| 38 |
export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
|
|
@@ -48,8 +51,8 @@ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
|
|
| 48 |
|
| 49 |
const controller = new AbortController();
|
| 50 |
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
| 51 |
-
|
| 52 |
-
const response = await fetch(testUrl, {
|
| 53 |
method: "GET",
|
| 54 |
cache: "no-store",
|
| 55 |
signal: controller.signal,
|
|
@@ -62,14 +65,17 @@ export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
|
|
| 62 |
}
|
| 63 |
|
| 64 |
const data = await response.json();
|
| 65 |
-
|
| 66 |
if (!data.features) {
|
| 67 |
throw new Error(
|
| 68 |
"Dataset info.json does not have the expected features structure",
|
| 69 |
);
|
| 70 |
}
|
| 71 |
-
|
| 72 |
-
datasetInfoCache.set(repoId, {
|
|
|
|
|
|
|
|
|
|
| 73 |
return data as DatasetInfo;
|
| 74 |
} catch (error) {
|
| 75 |
if (error instanceof Error) {
|
|
@@ -88,7 +94,9 @@ const SUPPORTED_VERSIONS = ["v3.0", "v2.1", "v2.0"];
|
|
| 88 |
* Returns both the validated version string and the dataset info in one call,
|
| 89 |
* avoiding a duplicate info.json fetch.
|
| 90 |
*/
|
| 91 |
-
export async function getDatasetVersionAndInfo(
|
|
|
|
|
|
|
| 92 |
const info = await getDatasetInfo(repoId);
|
| 93 |
const version = info.codebase_version;
|
| 94 |
if (!version) {
|
|
@@ -97,8 +105,8 @@ export async function getDatasetVersionAndInfo(repoId: string): Promise<{ versio
|
|
| 97 |
if (!SUPPORTED_VERSIONS.includes(version)) {
|
| 98 |
throw new Error(
|
| 99 |
`Dataset ${repoId} has codebase version ${version}, which is not supported. ` +
|
| 100 |
-
|
| 101 |
-
|
| 102 |
);
|
| 103 |
}
|
| 104 |
return { version, info };
|
|
|
|
| 32 |
}
|
| 33 |
|
| 34 |
// In-memory cache for dataset info (5 min TTL)
|
| 35 |
+
const datasetInfoCache = new Map<
|
| 36 |
+
string,
|
| 37 |
+
{ data: DatasetInfo; expiry: number }
|
| 38 |
+
>();
|
| 39 |
const CACHE_TTL_MS = 5 * 60 * 1000;
|
| 40 |
|
| 41 |
export async function getDatasetInfo(repoId: string): Promise<DatasetInfo> {
|
|
|
|
| 51 |
|
| 52 |
const controller = new AbortController();
|
| 53 |
const timeoutId = setTimeout(() => controller.abort(), 10000);
|
| 54 |
+
|
| 55 |
+
const response = await fetch(testUrl, {
|
| 56 |
method: "GET",
|
| 57 |
cache: "no-store",
|
| 58 |
signal: controller.signal,
|
|
|
|
| 65 |
}
|
| 66 |
|
| 67 |
const data = await response.json();
|
| 68 |
+
|
| 69 |
if (!data.features) {
|
| 70 |
throw new Error(
|
| 71 |
"Dataset info.json does not have the expected features structure",
|
| 72 |
);
|
| 73 |
}
|
| 74 |
+
|
| 75 |
+
datasetInfoCache.set(repoId, {
|
| 76 |
+
data: data as DatasetInfo,
|
| 77 |
+
expiry: Date.now() + CACHE_TTL_MS,
|
| 78 |
+
});
|
| 79 |
return data as DatasetInfo;
|
| 80 |
} catch (error) {
|
| 81 |
if (error instanceof Error) {
|
|
|
|
| 94 |
* Returns both the validated version string and the dataset info in one call,
|
| 95 |
* avoiding a duplicate info.json fetch.
|
| 96 |
*/
|
| 97 |
+
export async function getDatasetVersionAndInfo(
|
| 98 |
+
repoId: string,
|
| 99 |
+
): Promise<{ version: string; info: DatasetInfo }> {
|
| 100 |
const info = await getDatasetInfo(repoId);
|
| 101 |
const version = info.codebase_version;
|
| 102 |
if (!version) {
|
|
|
|
| 105 |
if (!SUPPORTED_VERSIONS.includes(version)) {
|
| 106 |
throw new Error(
|
| 107 |
`Dataset ${repoId} has codebase version ${version}, which is not supported. ` +
|
| 108 |
+
"This tool only works with dataset versions 3.0, 2.1, or 2.0. " +
|
| 109 |
+
"Please use a compatible dataset version.",
|
| 110 |
);
|
| 111 |
}
|
| 112 |
return { version, info };
|