Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Sync from huggingface/lerobot-dataset-visualizer via hub-sync
Browse files- AGENTS.md +147 -0
- CLAUDE.md +1 -140
- bun.lock +2 -2
- package.json +1 -1
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +145 -391
AGENTS.md
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# AGENTS.md β LeRobot Dataset Visualizer
|
| 2 |
+
|
| 3 |
+
## Package manager
|
| 4 |
+
|
| 5 |
+
Always use **bun** (`bun install`, `bun dev`, `bun run build`, `bun test`). Never use npm or yarn.
|
| 6 |
+
|
| 7 |
+
## Post-process β run after every code change
|
| 8 |
+
|
| 9 |
+
After making any code changes, always run these commands in order and fix any errors before finishing:
|
| 10 |
+
|
| 11 |
+
```
|
| 12 |
+
bun run format # auto-fix formatting (prettier)
|
| 13 |
+
bun run type-check # TypeScript: app + test files
|
| 14 |
+
bun run lint # ESLint (next lint)
|
| 15 |
+
bun test # unit tests
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
Or run them all at once (format first, then the full validate suite):
|
| 19 |
+
|
| 20 |
+
```
|
| 21 |
+
bun run format && bun run validate
|
| 22 |
+
```
|
| 23 |
+
|
| 24 |
+
`bun run validate` runs: type-check β lint β format:check β test
|
| 25 |
+
|
| 26 |
+
## Key scripts
|
| 27 |
+
|
| 28 |
+
```
|
| 29 |
+
bun dev # Next.js dev server
|
| 30 |
+
bun test # Run all unit tests (bun:test)
|
| 31 |
+
bun run type-check # tsc --noEmit (app) + tsc -p tsconfig.test.json --noEmit (tests)
|
| 32 |
+
bun run lint # next lint
|
| 33 |
+
bun run validate # type-check + lint + format:check + test
|
| 34 |
+
```
|
| 35 |
+
|
| 36 |
+
## Architecture
|
| 37 |
+
|
| 38 |
+
### Dataset version support
|
| 39 |
+
|
| 40 |
+
Three versions are supported. Version is detected from `meta/info.json` β `codebase_version`.
|
| 41 |
+
|
| 42 |
+
| Version | Path pattern | Episode metadata | Video |
|
| 43 |
+
| -------- | -------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
| 44 |
+
| **v2.0** | `data/{episode_chunk:03d}/episode_{episode_index:06d}.parquet` | None (computed from `chunks_size`) | Full file per episode |
|
| 45 |
+
| **v2.1** | Same as v2.0 | None | Full file per episode |
|
| 46 |
+
| **v3.0** | `data/chunk-{N:03d}/file-{N:03d}.parquet` | `meta/episodes/chunk-{N}/file-{N}.parquet` | Segmented (timestamps per episode, per camera) |
|
| 47 |
+
|
| 48 |
+
### Routing to parsers
|
| 49 |
+
|
| 50 |
+
`src/app/[org]/[dataset]/[episode]/fetch-data.ts` β `getEpisodeData()` dispatches to:
|
| 51 |
+
|
| 52 |
+
- `getEpisodeDataV2()` for v2.0 and v2.1
|
| 53 |
+
- `getEpisodeDataV3()` for v3.0
|
| 54 |
+
|
| 55 |
+
### `@huggingface/lerobot`
|
| 56 |
+
|
| 57 |
+
Locating an episode (its video URLs and offsets, its data file and row range) and reading camera sizes go through [`@huggingface/lerobot`](https://github.com/huggingface/huggingface.js/tree/main/packages/lerobot). Don't build v3 data, video or episode-metadata paths by hand.
|
| 58 |
+
|
| 59 |
+
- Create datasets with `leRobotDataset(repoId)` in `fetch-data.ts`: it passes the `DATASET_URL` endpoint and `authHeaders()`.
|
| 60 |
+
- `episodes({ offset, limit })` reads the v3 episode index across every metadata chunk. `offset` is a position in the listing, not an `episode_index`.
|
| 61 |
+
- `episode.data.fromRow` / `toRow` are row offsets **inside `episode.data.url`**, not dataset-wide frame indexes. Pass them straight to `rowStart` / `rowEnd`; never subtract the file's first `index`. `episode.data` is optional: it is absent when the data file can't be read.
|
| 62 |
+
- Episode charts come from `frames()` (numeric and boolean series, bookkeeping columns left out). `loadEpisodeFrames` reads the task and language columns (`TEXT_COLUMNS`) from the same file alongside it, since `frames()` returns numbers only.
|
| 63 |
+
- Camera sizes come from `parseInfo(...).cameras`. Never read them from `shape[0]` / `shape[1]`: some datasets declare channel-first shapes (`[3, H, W]`).
|
| 64 |
+
- To upgrade, bump the version in `package.json` and run `bun install`. Read the package's changes first: 0.0.4 changed what `fromRow` / `toRow` mean.
|
| 65 |
+
|
| 66 |
+
### v3.0 specifics
|
| 67 |
+
|
| 68 |
+
- Integer columns from parquet come out as **BigInt** β always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
|
| 69 |
+
- Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
|
| 70 |
+
- Multi-task episodes: episodes carry a `tasks` list (`list[str]`) β prefer it over the legacy single `task_index` lookup. `EpisodeMetadataV3.tasks?: string[]` exposes it.
|
| 71 |
+
- `meta/tasks.parquet` lookup: rows are **not** ordered by `task_index`, and the task string lives in a named pandas index (`__index_level_0__`). Always filter by the `task_index` **column** (`row.task_index === taskIndexNum`), never by row position.
|
| 72 |
+
|
| 73 |
+
### v2.x path construction
|
| 74 |
+
|
| 75 |
+
```ts
|
| 76 |
+
formatStringWithVars(info.data_path, {
|
| 77 |
+
episode_chunk: Math.floor(episodeId / chunkSize)
|
| 78 |
+
.toString()
|
| 79 |
+
.padStart(3, "0"),
|
| 80 |
+
episode_index: episodeId.toString().padStart(6, "0"),
|
| 81 |
+
});
|
| 82 |
+
// β "data/000/episode_000042.parquet"
|
| 83 |
+
```
|
| 84 |
+
|
| 85 |
+
`formatStringWithVars` strips `:03d` format specifiers β padding must be done by the caller.
|
| 86 |
+
|
| 87 |
+
## Key files
|
| 88 |
+
|
| 89 |
+
| File | Purpose |
|
| 90 |
+
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| 91 |
+
| `src/app/[org]/[dataset]/[episode]/fetch-data.ts` | Main data-loading entry point; v2/v3 parsers; `computeColumnMinMax` |
|
| 92 |
+
| `src/utils/versionUtils.ts` | `getDatasetInfo`, `getDatasetVersionAndInfo`, `buildVersionedUrl` |
|
| 93 |
+
| `src/utils/parquetUtils.ts` | `fetchParquetFile`, `readParquetAsObjects`, `formatStringWithVars` |
|
| 94 |
+
| `src/utils/dataProcessing.ts` | Chart grouping pipeline: `buildSuffixGroupsMap` β `computeGroupStats` β `groupByScale` β `flattenScaleGroups` β `processChartDataGroups` |
|
| 95 |
+
| `src/utils/typeGuards.ts` | `bigIntToNumber`, `isNumeric`, `isValidTaskIndex`, etc. |
|
| 96 |
+
| `src/utils/constants.ts` | `PADDING`, `EXCLUDED_COLUMNS`, `CHART_CONFIG`, `THRESHOLDS` |
|
| 97 |
+
| `src/types/` | TypeScript types: `DatasetVersion`, `EpisodeMetadataV3`, `VideoInfo`, `ChartDataGroup`, etc. |
|
| 98 |
+
|
| 99 |
+
## Chart data pipeline
|
| 100 |
+
|
| 101 |
+
Series keys use `" | "` as delimiter (e.g. `observation.state | 0`).
|
| 102 |
+
`groupRowBySuffix` groups by **suffix**: if two different prefixes share suffix `"0"` (e.g. `observation.state | 0` and `action | 0`), they are merged under `result["0"] = { "observation.state": ..., "action": ... }`. A series with a unique suffix stays flat with its full original key.
|
| 103 |
+
|
| 104 |
+
## Testing
|
| 105 |
+
|
| 106 |
+
- Test files live in `**/__tests__/` directories alongside source
|
| 107 |
+
- Uses `bun:test` (built-in, no extra install)
|
| 108 |
+
- BigInt literals (`42n`) require `tsconfig.test.json` (target ES2020) β test files are excluded from `tsconfig.json`
|
| 109 |
+
- `@types/bun` is installed as a devDependency for `bun:test` type resolution
|
| 110 |
+
- Mocking fetch: `globalThis.fetch = mock(() => Promise.resolve(new Response(...))) as unknown as typeof fetch`
|
| 111 |
+
- CI: `.github/workflows/test.yml` runs `bun test` on push/PR to main
|
| 112 |
+
|
| 113 |
+
## URL structure
|
| 114 |
+
|
| 115 |
+
All dataset URLs:
|
| 116 |
+
|
| 117 |
+
```
|
| 118 |
+
https://huggingface.co/datasets/{org}/{dataset}/resolve/main/{path}
|
| 119 |
+
```
|
| 120 |
+
|
| 121 |
+
Built by `buildVersionedUrl(repoId, version, path)`. The `version` param is accepted but currently unused in the URL (always `main` revision).
|
| 122 |
+
|
| 123 |
+
## Excluded columns (not shown in charts)
|
| 124 |
+
|
| 125 |
+
Reserved/bookkeeping columns from lerobot β see `EXCLUDED_COLUMNS` in `src/utils/constants.ts`:
|
| 126 |
+
|
| 127 |
+
- v2.x: `timestamp`, `frame_index`, `episode_index`, `index`, `task_index`, `next.reward`, `next.done`, `next.truncated`
|
| 128 |
+
- v3.0: `index`, `task_index`, `episode_index`, `frame_index`, `next.reward`, `next.done`, `next.truncated`, `subtask_index`
|
| 129 |
+
|
| 130 |
+
## 3D URDF viewer (`src/components/urdf-viewer.tsx`)
|
| 131 |
+
|
| 132 |
+
- URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` β base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
|
| 133 |
+
- Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
|
| 134 |
+
- **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
|
| 135 |
+
- **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` β pick the archetype color directly inside `loadMeshCb`.
|
| 136 |
+
- **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
|
| 137 |
+
- Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
|
| 138 |
+
|
| 139 |
+
## Design system
|
| 140 |
+
|
| 141 |
+
CSS tokens in `src/app/globals.css` (Tailwind v4 `@theme inline`):
|
| 142 |
+
|
| 143 |
+
- Surfaces: `--bg #0a0e17`, `--surface-0`, `--surface-1`, `--surface-2`
|
| 144 |
+
- Text: `--text-primary`, `--text-muted`, `--text-faint`
|
| 145 |
+
- Accent: `--accent #38bdf8` (cyan) β primary interactive color across UI
|
| 146 |
+
- Helpers: `.panel`, `.panel-raised`, `.tabular` (tabular-nums)
|
| 147 |
+
- **Color semantics**: cyan = primary/active, orange (`orange-400/500`) is reserved for **flagged-episode** UI only β don't reuse it for generic accents.
|
CLAUDE.md
CHANGED
|
@@ -1,140 +1 @@
|
|
| 1 |
-
|
| 2 |
-
|
| 3 |
-
## Package manager
|
| 4 |
-
|
| 5 |
-
Always use **bun** (`bun install`, `bun dev`, `bun run build`, `bun test`). Never use npm or yarn.
|
| 6 |
-
|
| 7 |
-
## Post-process β run after every code change
|
| 8 |
-
|
| 9 |
-
After making any code changes, always run these commands in order and fix any errors before finishing:
|
| 10 |
-
|
| 11 |
-
```
|
| 12 |
-
bun run format # auto-fix formatting (prettier)
|
| 13 |
-
bun run type-check # TypeScript: app + test files
|
| 14 |
-
bun run lint # ESLint (next lint)
|
| 15 |
-
bun test # unit tests
|
| 16 |
-
```
|
| 17 |
-
|
| 18 |
-
Or run them all at once (format first, then the full validate suite):
|
| 19 |
-
|
| 20 |
-
```
|
| 21 |
-
bun run format && bun run validate
|
| 22 |
-
```
|
| 23 |
-
|
| 24 |
-
`bun run validate` runs: type-check β lint β format:check β test
|
| 25 |
-
|
| 26 |
-
## Key scripts
|
| 27 |
-
|
| 28 |
-
```
|
| 29 |
-
bun dev # Next.js dev server
|
| 30 |
-
bun test # Run all unit tests (bun:test)
|
| 31 |
-
bun run type-check # tsc --noEmit (app) + tsc -p tsconfig.test.json --noEmit (tests)
|
| 32 |
-
bun run lint # next lint
|
| 33 |
-
bun run validate # type-check + lint + format:check
|
| 34 |
-
```
|
| 35 |
-
|
| 36 |
-
## Architecture
|
| 37 |
-
|
| 38 |
-
### Dataset version support
|
| 39 |
-
|
| 40 |
-
Three versions are supported. Version is detected from `meta/info.json` β `codebase_version`.
|
| 41 |
-
|
| 42 |
-
| Version | Path pattern | Episode metadata | Video |
|
| 43 |
-
| -------- | ----------------------------------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
|
| 44 |
-
| **v2.0** | `data/{episode_chunk:03d}/episode_{episode_index:06d}.parquet` | None (computed from `chunks_size`) | Full file per episode |
|
| 45 |
-
| **v2.1** | Same as v2.0 | None | Full file per episode |
|
| 46 |
-
| **v3.0** | `data/chunk-{N:03d}/file-{N:03d}.parquet` (via `buildV3DataPath`) | `meta/episodes/chunk-{N}/file-{N}.parquet` | Segmented (timestamps per episode, per camera) |
|
| 47 |
-
|
| 48 |
-
### Routing to parsers
|
| 49 |
-
|
| 50 |
-
`src/app/[org]/[dataset]/[episode]/fetch-data.ts` β `getEpisodeData()` dispatches to:
|
| 51 |
-
|
| 52 |
-
- `getEpisodeDataV2()` for v2.0 and v2.1
|
| 53 |
-
- `getEpisodeDataV3()` for v3.0
|
| 54 |
-
|
| 55 |
-
### v3.0 specifics
|
| 56 |
-
|
| 57 |
-
- Episode metadata row has named keys (`episode_index`, `data/chunk_index`, `data/file_index`, `dataset_from_index`, `dataset_to_index`, `videos/{key}/chunk_index`, etc.)
|
| 58 |
-
- Integer columns from parquet come out as **BigInt** β always use `bigIntToNumber()` from `src/utils/typeGuards.ts`
|
| 59 |
-
- Row-range selection: `dataset_from_index` / `dataset_to_index` allow reading only the episode's rows from a shared parquet file
|
| 60 |
-
- Fallback format uses numeric keys `"0"`.."9"` when column names are unavailable
|
| 61 |
-
- Episode metadata can span **multiple chunks** (when episode count exceeds `chunks_size`). Always walk via the `iterateEpisodeMetadataFilesV3(repoId, version)` async generator in `fetch-data.ts` β it advances chunk-000 β chunk-001 β β¦ and stops on the first missing `file-000`. Never hardcode `chunk-000`.
|
| 62 |
-
- Multi-task episodes: episode-metadata rows carry a `tasks` field (`list[str]`) β prefer it over the legacy single `task_index` lookup. `EpisodeMetadataV3.tasks?: string[]` exposes it.
|
| 63 |
-
- `meta/tasks.parquet` lookup: rows are **not** ordered by `task_index`, and the task string lives in a named pandas index (`__index_level_0__`). Always filter by the `task_index` **column** (`row.task_index === taskIndexNum`), never by row position.
|
| 64 |
-
|
| 65 |
-
### v2.x path construction
|
| 66 |
-
|
| 67 |
-
```ts
|
| 68 |
-
formatStringWithVars(info.data_path, {
|
| 69 |
-
episode_chunk: Math.floor(episodeId / chunkSize)
|
| 70 |
-
.toString()
|
| 71 |
-
.padStart(3, "0"),
|
| 72 |
-
episode_index: episodeId.toString().padStart(6, "0"),
|
| 73 |
-
});
|
| 74 |
-
// β "data/000/episode_000042.parquet"
|
| 75 |
-
```
|
| 76 |
-
|
| 77 |
-
`formatStringWithVars` strips `:03d` format specifiers β padding must be done by the caller.
|
| 78 |
-
|
| 79 |
-
## Key files
|
| 80 |
-
|
| 81 |
-
| File | Purpose |
|
| 82 |
-
| ------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
|
| 83 |
-
| `src/app/[org]/[dataset]/[episode]/fetch-data.ts` | Main data-loading entry point; v2/v3 parsers; `computeColumnMinMax` |
|
| 84 |
-
| `src/utils/versionUtils.ts` | `getDatasetInfo`, `getDatasetVersionAndInfo`, `buildVersionedUrl` |
|
| 85 |
-
| `src/utils/stringFormatting.ts` | `buildV3DataPath`, `buildV3VideoPath`, `buildV3EpisodesMetadataPath`, padding helpers |
|
| 86 |
-
| `src/utils/parquetUtils.ts` | `fetchParquetFile`, `readParquetAsObjects`, `formatStringWithVars` |
|
| 87 |
-
| `src/utils/dataProcessing.ts` | Chart grouping pipeline: `buildSuffixGroupsMap` β `computeGroupStats` β `groupByScale` β `flattenScaleGroups` β `processChartDataGroups` |
|
| 88 |
-
| `src/utils/typeGuards.ts` | `bigIntToNumber`, `isNumeric`, `isValidTaskIndex`, etc. |
|
| 89 |
-
| `src/utils/constants.ts` | `PADDING`, `EXCLUDED_COLUMNS`, `CHART_CONFIG`, `THRESHOLDS` |
|
| 90 |
-
| `src/types/` | TypeScript types: `DatasetVersion`, `EpisodeMetadataV3`, `VideoInfo`, `ChartDataGroup`, etc. |
|
| 91 |
-
|
| 92 |
-
## Chart data pipeline
|
| 93 |
-
|
| 94 |
-
Series keys use `" | "` as delimiter (e.g. `observation.state | 0`).
|
| 95 |
-
`groupRowBySuffix` groups by **suffix**: if two different prefixes share suffix `"0"` (e.g. `observation.state | 0` and `action | 0`), they are merged under `result["0"] = { "observation.state": ..., "action": ... }`. A series with a unique suffix stays flat with its full original key.
|
| 96 |
-
|
| 97 |
-
## Testing
|
| 98 |
-
|
| 99 |
-
- Test files live in `**/__tests__/` directories alongside source
|
| 100 |
-
- Uses `bun:test` (built-in, no extra install)
|
| 101 |
-
- BigInt literals (`42n`) require `tsconfig.test.json` (target ES2020) β test files are excluded from `tsconfig.json`
|
| 102 |
-
- `@types/bun` is installed as a devDependency for `bun:test` type resolution
|
| 103 |
-
- Mocking fetch: `globalThis.fetch = mock(() => Promise.resolve(new Response(...))) as unknown as typeof fetch`
|
| 104 |
-
- CI: `.github/workflows/test.yml` runs `bun test` on push/PR to main
|
| 105 |
-
|
| 106 |
-
## URL structure
|
| 107 |
-
|
| 108 |
-
All dataset URLs:
|
| 109 |
-
|
| 110 |
-
```
|
| 111 |
-
https://huggingface.co/datasets/{org}/{dataset}/resolve/main/{path}
|
| 112 |
-
```
|
| 113 |
-
|
| 114 |
-
Built by `buildVersionedUrl(repoId, version, path)`. The `version` param is accepted but currently unused in the URL (always `main` revision).
|
| 115 |
-
|
| 116 |
-
## Excluded columns (not shown in charts)
|
| 117 |
-
|
| 118 |
-
Reserved/bookkeeping columns from lerobot β see `EXCLUDED_COLUMNS` in `src/utils/constants.ts`:
|
| 119 |
-
|
| 120 |
-
- v2.x: `timestamp`, `frame_index`, `episode_index`, `index`, `task_index`, `next.reward`, `next.done`, `next.truncated`
|
| 121 |
-
- v3.0: `index`, `task_index`, `episode_index`, `frame_index`, `next.reward`, `next.done`, `next.truncated`, `subtask_index`
|
| 122 |
-
|
| 123 |
-
## 3D URDF viewer (`src/components/urdf-viewer.tsx`)
|
| 124 |
-
|
| 125 |
-
- URDFs and meshes are hosted in the HF bucket `lerobot/robot-urdfs` β base URL `https://huggingface.co/buckets/lerobot/robot-urdfs/resolve` (no `/main` segment; buckets are unbranched). Override with `NEXT_PUBLIC_URDF_BASE_URL` for local development.
|
| 126 |
-
- Asset layout under the bucket: `g1/`, `openarm/`, `so101/` (both SO-100 and SO-101 live here).
|
| 127 |
-
- **URDFLoader gotcha**: after our `loadMeshCb` returns, `URDFLoader.js` does `if (obj instanceof THREE.Mesh) obj.material = <urdf-material>`, overwriting any material we set. Workaround: wrap the loaded mesh in a `THREE.Group` so the `instanceof Mesh` check fails. DAE returns a Group already; STL must be wrapped explicitly.
|
| 128 |
-
- **STLLoader event ordering**: `manager.itemEnd(url)` fires _before_ the user `onLoad` callback, so `manager.onLoad` can fire before meshes are attached to the robot tree. Defer post-load work (auto-fit camera, shadow flags) with `setTimeout(..., 0)`. Don't try to rebuild materials in `manager.onLoad` β pick the archetype color directly inside `loadMeshCb`.
|
| 129 |
-
- **OpenArm DAE files ship 23 stray `PointLight`s** that drown out scene lighting. Strip non-`AmbientLight` lights from `collada.scene` before adding it to the robot.
|
| 130 |
-
- Scene setup: `<Canvas shadows>` with `ACESFilmicToneMapping` (exposure 0.9), 3-point directional + ambient lights, `<Environment preset="studio" background={false} />`, `<color attach="background" args={["#1a2433"]} />`. `<OrbitControls makeDefault />` is required so `useThree().controls` exposes the controls for auto-fit.
|
| 131 |
-
|
| 132 |
-
## Design system
|
| 133 |
-
|
| 134 |
-
CSS tokens in `src/app/globals.css` (Tailwind v4 `@theme inline`):
|
| 135 |
-
|
| 136 |
-
- Surfaces: `--bg #0a0e17`, `--surface-0`, `--surface-1`, `--surface-2`
|
| 137 |
-
- Text: `--text-primary`, `--text-muted`, `--text-faint`
|
| 138 |
-
- Accent: `--accent #38bdf8` (cyan) β primary interactive color across UI
|
| 139 |
-
- Helpers: `.panel`, `.panel-raised`, `.tabular` (tabular-nums)
|
| 140 |
-
- **Color semantics**: cyan = primary/active, orange (`orange-400/500`) is reserved for **flagged-episode** UI only β don't reuse it for generic accents.
|
|
|
|
| 1 |
+
@AGENTS.md
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
bun.lock
CHANGED
|
@@ -6,7 +6,7 @@
|
|
| 6 |
"name": "lerobot-viewer",
|
| 7 |
"dependencies": {
|
| 8 |
"@huggingface/hub": "^2.11.0",
|
| 9 |
-
"@huggingface/lerobot": "^0.0.
|
| 10 |
"@react-three/drei": "^10.7.7",
|
| 11 |
"@react-three/fiber": "^9.5.0",
|
| 12 |
"hyparquet": "^1.12.1",
|
|
@@ -67,7 +67,7 @@
|
|
| 67 |
|
| 68 |
"@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
|
| 69 |
|
| 70 |
-
"@huggingface/lerobot": ["@huggingface/lerobot@0.0.
|
| 71 |
|
| 72 |
"@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
|
| 73 |
|
|
|
|
| 6 |
"name": "lerobot-viewer",
|
| 7 |
"dependencies": {
|
| 8 |
"@huggingface/hub": "^2.11.0",
|
| 9 |
+
"@huggingface/lerobot": "^0.0.8",
|
| 10 |
"@react-three/drei": "^10.7.7",
|
| 11 |
"@react-three/fiber": "^9.5.0",
|
| 12 |
"hyparquet": "^1.12.1",
|
|
|
|
| 67 |
|
| 68 |
"@huggingface/hub": ["@huggingface/hub@2.11.0", "", { "dependencies": { "@huggingface/tasks": "^0.19.90" }, "optionalDependencies": { "cli-progress": "^3.12.0" }, "bin": { "hfjs": "dist/cli.js" } }, "sha512-WS6QGaXYeBVFlaB4SOn6z4LGUpLB5kRZNL08uUni4izX353KxiwwZMK5+/AWX86MJh8SMZNa/JFcvFCcQsbszQ=="],
|
| 69 |
|
| 70 |
+
"@huggingface/lerobot": ["@huggingface/lerobot@0.0.8", "", { "dependencies": { "hyparquet": "^1.25.1" } }, "sha512-TeA8U5/idkQH0dmhb79+wkZHvrxkjB5Y+TllirJRg3vExl5SC2/E8LXZbRWSmfZibr7YD4w1kSGxeRL0f8/2/Q=="],
|
| 71 |
|
| 72 |
"@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
|
| 73 |
|
package.json
CHANGED
|
@@ -16,7 +16,7 @@
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@huggingface/hub": "^2.11.0",
|
| 19 |
-
"@huggingface/lerobot": "^0.0.
|
| 20 |
"@react-three/drei": "^10.7.7",
|
| 21 |
"@react-three/fiber": "^9.5.0",
|
| 22 |
"hyparquet": "^1.12.1",
|
|
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@huggingface/hub": "^2.11.0",
|
| 19 |
+
"@huggingface/lerobot": "^0.0.8",
|
| 20 |
"@react-three/drei": "^10.7.7",
|
| 21 |
"@react-three/fiber": "^9.5.0",
|
| 22 |
"hyparquet": "^1.12.1",
|
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -10,7 +10,7 @@ import {
|
|
| 10 |
buildVersionedUrl,
|
| 11 |
getDatasetStats,
|
| 12 |
} from "@/utils/versionUtils";
|
| 13 |
-
import {
|
| 14 |
import {
|
| 15 |
processChartDataGroups,
|
| 16 |
groupRowBySuffix,
|
|
@@ -23,7 +23,7 @@ import {
|
|
| 23 |
} from "@/utils/colormaps";
|
| 24 |
import type { VideoInfo, AdjacentEpisodeVideos } from "@/types";
|
| 25 |
import { LeRobotDataset, parseInfo } from "@huggingface/lerobot";
|
| 26 |
-
import type { LeRobotEpisode,
|
| 27 |
import { authHeaders } from "@/utils/auth";
|
| 28 |
|
| 29 |
const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
|
|
@@ -197,11 +197,6 @@ type EpisodeMetadataV3 = {
|
|
| 197 |
[key: string]: string | number | string[] | undefined;
|
| 198 |
};
|
| 199 |
|
| 200 |
-
type ColumnDef = {
|
| 201 |
-
key: string;
|
| 202 |
-
value: string[];
|
| 203 |
-
};
|
| 204 |
-
|
| 205 |
function parsePositiveIntEnv(
|
| 206 |
value: string | undefined,
|
| 207 |
fallback: number,
|
|
@@ -526,9 +521,6 @@ async function getEpisodeDataV2(
|
|
| 526 |
info: DatasetMetadata,
|
| 527 |
episodeId: number,
|
| 528 |
): Promise<EpisodeData> {
|
| 529 |
-
const chunkSize = Math.max(1, info.chunks_size || 1000);
|
| 530 |
-
const episode_chunk = Math.floor(episodeId / chunkSize);
|
| 531 |
-
|
| 532 |
const datasetInfo: DatasetDisplayInfo = {
|
| 533 |
repoId,
|
| 534 |
total_frames: info.total_frames,
|
|
@@ -553,96 +545,19 @@ async function getEpisodeDataV2(
|
|
| 553 |
.map((x) => parseInt(x.trim(), 10))
|
| 554 |
.filter((x) => !isNaN(x));
|
| 555 |
|
| 556 |
-
|
| 557 |
const videosInfo =
|
| 558 |
-
info.video_path !== null
|
| 559 |
-
? toVideosInfo(await loadLeRobotEpisode(repoId, episodeId), info, false)
|
| 560 |
-
: [];
|
| 561 |
-
|
| 562 |
-
// Column data
|
| 563 |
-
const columnNames = Object.entries(info.features)
|
| 564 |
-
.filter(
|
| 565 |
-
([, value]) =>
|
| 566 |
-
["float32", "int32"].includes(value.dtype) && value.shape.length === 1,
|
| 567 |
-
)
|
| 568 |
-
.map(([key, { shape }]) => ({ key, length: shape[0] }));
|
| 569 |
-
|
| 570 |
-
// Exclude specific columns
|
| 571 |
-
const excludedColumns = EXCLUDED_COLUMNS.V2 as readonly string[];
|
| 572 |
-
const filteredColumns = columnNames.filter(
|
| 573 |
-
(column) => !excludedColumns.includes(column.key),
|
| 574 |
-
);
|
| 575 |
-
const columns: ColumnDef[] = filteredColumns.map(({ key }) => {
|
| 576 |
-
let column_names: unknown = info.features[key].names;
|
| 577 |
-
while (typeof column_names === "object" && column_names !== null) {
|
| 578 |
-
if (Array.isArray(column_names)) break;
|
| 579 |
-
column_names = Object.values(column_names)[0];
|
| 580 |
-
}
|
| 581 |
-
return {
|
| 582 |
-
key,
|
| 583 |
-
value: Array.isArray(column_names)
|
| 584 |
-
? column_names.map(
|
| 585 |
-
(name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
|
| 586 |
-
)
|
| 587 |
-
: Array.from(
|
| 588 |
-
{ length: columnNames.find((c) => c.key === key)?.length ?? 1 },
|
| 589 |
-
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
| 590 |
-
),
|
| 591 |
-
};
|
| 592 |
-
});
|
| 593 |
|
| 594 |
-
const
|
| 595 |
-
|
| 596 |
-
|
| 597 |
-
formatStringWithVars(info.data_path, {
|
| 598 |
-
episode_chunk: episode_chunk
|
| 599 |
-
.toString()
|
| 600 |
-
.padStart(PADDING.CHUNK_INDEX, "0"),
|
| 601 |
-
episode_index: episodeId.toString().padStart(PADDING.EPISODE_INDEX, "0"),
|
| 602 |
-
}),
|
| 603 |
-
);
|
| 604 |
-
|
| 605 |
-
const arrayBuffer = await fetchParquetFile(parquetUrl);
|
| 606 |
-
const parquetColumns = Array.from(
|
| 607 |
-
new Set([
|
| 608 |
-
"timestamp",
|
| 609 |
-
"task",
|
| 610 |
-
"task_index",
|
| 611 |
-
"language_instruction",
|
| 612 |
-
// v3.1 language schema (lerobot#3467) β list<struct{...}> columns the
|
| 613 |
-
// annotations panel renders / edits. lerobot can write these onto v2.x
|
| 614 |
-
// datasets too (the codebase_version stays 2.x), so a v2 episode may
|
| 615 |
-
// carry annotations. Requesting columns absent from the parquet schema
|
| 616 |
-
// is a no-op in hyparquet, so this is safe for un-annotated datasets.
|
| 617 |
-
"language_persistent",
|
| 618 |
-
"language_events",
|
| 619 |
-
...filteredColumns.map((c) => c.key),
|
| 620 |
-
]),
|
| 621 |
-
);
|
| 622 |
-
const allData = await readParquetAsObjects(arrayBuffer, parquetColumns);
|
| 623 |
-
|
| 624 |
-
// Frame timestamps (sorted, seconds) let the annotations editor snap atoms
|
| 625 |
-
// to exact frames. v3.1 language atoms are broadcast in `language_persistent`
|
| 626 |
-
// and fired per-row in `language_events`; extract them so annotations written
|
| 627 |
-
// onto v2.x datasets render in the panel/timeline just like on v3.0.
|
| 628 |
-
const frameTimestamps = allData
|
| 629 |
-
.map((r) => {
|
| 630 |
-
const t = r.timestamp;
|
| 631 |
-
return typeof t === "number"
|
| 632 |
-
? t
|
| 633 |
-
: typeof t === "bigint"
|
| 634 |
-
? Number(t)
|
| 635 |
-
: Number.NaN;
|
| 636 |
-
})
|
| 637 |
-
.filter((t) => Number.isFinite(t))
|
| 638 |
-
.sort((a, b) => a - b);
|
| 639 |
-
const languageAtoms = extractLanguageAtoms(allData);
|
| 640 |
|
| 641 |
// Extract task from language_instruction fields, task field, or tasks.jsonl
|
| 642 |
let task: string | undefined;
|
| 643 |
|
| 644 |
-
if (
|
| 645 |
-
const firstRow =
|
| 646 |
const languageInstructions: string[] = [];
|
| 647 |
|
| 648 |
if (typeof firstRow.language_instruction === "string") {
|
|
@@ -664,11 +579,11 @@ async function getEpisodeDataV2(
|
|
| 664 |
}
|
| 665 |
}
|
| 666 |
|
| 667 |
-
if (!task &&
|
| 668 |
-
task =
|
| 669 |
}
|
| 670 |
|
| 671 |
-
if (!task &&
|
| 672 |
try {
|
| 673 |
const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
|
| 674 |
const tasksResponse = await fetch(tasksUrl, { cache: "no-store" });
|
|
@@ -681,7 +596,7 @@ async function getEpisodeDataV2(
|
|
| 681 |
.map((line) => JSON.parse(line));
|
| 682 |
|
| 683 |
if (tasksData && tasksData.length > 0) {
|
| 684 |
-
const taskIndex =
|
| 685 |
const taskIndexNum =
|
| 686 |
typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
|
| 687 |
const taskData = tasksData.find(
|
|
@@ -697,28 +612,14 @@ async function getEpisodeDataV2(
|
|
| 697 |
}
|
| 698 |
}
|
| 699 |
|
| 700 |
-
|
| 701 |
-
|
| 702 |
-
|
| 703 |
-
|
| 704 |
-
|
| 705 |
-
|
| 706 |
-
|
| 707 |
-
|
| 708 |
-
obj["timestamp"] = Number(row.timestamp);
|
| 709 |
-
for (const col of columns) {
|
| 710 |
-
const rawVal = row[col.key];
|
| 711 |
-
if (Array.isArray(rawVal)) {
|
| 712 |
-
rawVal.forEach((v: unknown, i: number) => {
|
| 713 |
-
if (i < col.value.length) obj[col.value[i]] = Number(v);
|
| 714 |
-
});
|
| 715 |
-
} else if (rawVal !== undefined) {
|
| 716 |
-
obj[col.value[0]] = Number(rawVal);
|
| 717 |
-
}
|
| 718 |
-
}
|
| 719 |
-
return obj;
|
| 720 |
-
});
|
| 721 |
-
const sampledChartData = evenlySampleArray(chartData, MAX_EPISODE_POINTS);
|
| 722 |
|
| 723 |
// List of columns that are ignored (e.g., 2D or 3D data)
|
| 724 |
const ignoredColumns = Object.entries(info.features)
|
|
@@ -728,32 +629,17 @@ async function getEpisodeDataV2(
|
|
| 728 |
)
|
| 729 |
.map(([key]) => key);
|
| 730 |
|
| 731 |
-
// Process chart data into organized groups using utility function
|
| 732 |
-
const chartGroups = processChartDataGroups(seriesNames, sampledChartData);
|
| 733 |
-
|
| 734 |
const duration =
|
| 735 |
-
|
| 736 |
-
?
|
| 737 |
: 0;
|
| 738 |
|
| 739 |
-
const chartDataGroups = chartGroups.map((group) =>
|
| 740 |
-
sampledChartData.map((row) => {
|
| 741 |
-
const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
|
| 742 |
-
// Ensure timestamp is always a number at the top level
|
| 743 |
-
return {
|
| 744 |
-
...grouped,
|
| 745 |
-
timestamp:
|
| 746 |
-
typeof grouped.timestamp === "number" ? grouped.timestamp : 0,
|
| 747 |
-
};
|
| 748 |
-
}),
|
| 749 |
-
);
|
| 750 |
-
|
| 751 |
return {
|
| 752 |
datasetInfo,
|
| 753 |
episodeId,
|
| 754 |
videosInfo,
|
| 755 |
chartDataGroups,
|
| 756 |
-
flatChartData
|
| 757 |
episodes,
|
| 758 |
ignoredColumns,
|
| 759 |
duration,
|
|
@@ -801,7 +687,7 @@ async function getEpisodeDataV3(
|
|
| 801 |
version,
|
| 802 |
info,
|
| 803 |
episodeMetadata,
|
| 804 |
-
leRobotEpisode
|
| 805 |
);
|
| 806 |
|
| 807 |
const duration = episodeMetadata.length
|
|
@@ -829,7 +715,7 @@ async function loadEpisodeDataV3(
|
|
| 829 |
version: string,
|
| 830 |
info: DatasetMetadata,
|
| 831 |
episodeMetadata: EpisodeMetadataV3,
|
| 832 |
-
|
| 833 |
): Promise<{
|
| 834 |
chartDataGroups: ChartRow[][];
|
| 835 |
flatChartData: Record<string, number>[];
|
|
@@ -839,78 +725,44 @@ async function loadEpisodeDataV3(
|
|
| 839 |
frameTimestamps?: number[];
|
| 840 |
}> {
|
| 841 |
try {
|
| 842 |
-
|
| 843 |
-
const
|
| 844 |
-
const v3DataColumns = Array.from(
|
| 845 |
-
new Set([
|
| 846 |
-
"index",
|
| 847 |
-
"timestamp",
|
| 848 |
-
"task_index",
|
| 849 |
-
"language_instruction",
|
| 850 |
-
"language_instruction_2",
|
| 851 |
-
"language_instruction_3",
|
| 852 |
-
// v3.1 language schema (lerobot#3467) β list<struct{...}> columns
|
| 853 |
-
// that the annotations panel renders / edits.
|
| 854 |
-
"language_persistent",
|
| 855 |
-
"language_events",
|
| 856 |
-
...Object.entries(info.features)
|
| 857 |
-
.filter(([, feature]) => {
|
| 858 |
-
const dtype = feature.dtype.toLowerCase();
|
| 859 |
-
const isNumericOrBool = [
|
| 860 |
-
"float32",
|
| 861 |
-
"float64",
|
| 862 |
-
"int8",
|
| 863 |
-
"int16",
|
| 864 |
-
"int32",
|
| 865 |
-
"int64",
|
| 866 |
-
"uint8",
|
| 867 |
-
"uint16",
|
| 868 |
-
"uint32",
|
| 869 |
-
"uint64",
|
| 870 |
-
"bool",
|
| 871 |
-
"boolean",
|
| 872 |
-
].includes(dtype);
|
| 873 |
-
return isNumericOrBool && feature.shape.length <= 1;
|
| 874 |
-
})
|
| 875 |
-
.map(([key]) => key),
|
| 876 |
-
]),
|
| 877 |
-
);
|
| 878 |
-
// `fromRow`/`toRow` are offsets inside `data.url`, not dataset-wide frame indexes.
|
| 879 |
-
const episodeRows = await readParquetAsObjects(parquetFile, v3DataColumns, {
|
| 880 |
-
rowStart: data.fromRow,
|
| 881 |
-
rowEnd: data.toRow,
|
| 882 |
-
});
|
| 883 |
-
|
| 884 |
-
// Extract frame timestamps from the *full* (non-sampled) row set so the
|
| 885 |
-
// annotations editor can snap to the exact frame the user is on.
|
| 886 |
-
const frameTimestamps = episodeRows
|
| 887 |
-
.map((r) => {
|
| 888 |
-
const t = r.timestamp;
|
| 889 |
-
return typeof t === "number"
|
| 890 |
-
? t
|
| 891 |
-
: typeof t === "bigint"
|
| 892 |
-
? Number(t)
|
| 893 |
-
: Number.NaN;
|
| 894 |
-
})
|
| 895 |
-
.filter((t) => Number.isFinite(t))
|
| 896 |
-
.sort((a, b) => a - b);
|
| 897 |
|
| 898 |
-
|
| 899 |
-
|
| 900 |
-
if (episodeData.length === 0) {
|
| 901 |
return {
|
| 902 |
chartDataGroups: [],
|
| 903 |
flatChartData: [],
|
| 904 |
ignoredColumns: [],
|
| 905 |
task: undefined,
|
| 906 |
-
languageAtoms: extractLanguageAtoms(
|
| 907 |
frameTimestamps,
|
| 908 |
};
|
| 909 |
}
|
| 910 |
|
| 911 |
-
//
|
| 912 |
-
|
| 913 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 914 |
|
| 915 |
// Prefer the authoritative `tasks` list on the episode's own metadata
|
| 916 |
// (v3.0 stores it as list[str] β see lerobot dataset_metadata.save_episode).
|
|
@@ -920,7 +772,7 @@ async function loadEpisodeDataV3(
|
|
| 920 |
}
|
| 921 |
|
| 922 |
// Fall back to per-frame language_instruction fields
|
| 923 |
-
if (!task &&
|
| 924 |
const languageInstructions: string[] = [];
|
| 925 |
|
| 926 |
const extractInstructions = (row: Record<string, unknown>) => {
|
|
@@ -936,15 +788,15 @@ async function loadEpisodeDataV3(
|
|
| 936 |
}
|
| 937 |
};
|
| 938 |
|
| 939 |
-
extractInstructions(
|
| 940 |
|
| 941 |
// If no instructions in first row, check middle and last rows
|
| 942 |
-
if (languageInstructions.length === 0 &&
|
| 943 |
for (const idx of [
|
| 944 |
-
Math.floor(
|
| 945 |
-
|
| 946 |
]) {
|
| 947 |
-
extractInstructions(
|
| 948 |
if (languageInstructions.length > 0) break;
|
| 949 |
}
|
| 950 |
}
|
|
@@ -955,7 +807,7 @@ async function loadEpisodeDataV3(
|
|
| 955 |
}
|
| 956 |
|
| 957 |
// Fall back to tasks metadata parquet
|
| 958 |
-
if (!task &&
|
| 959 |
try {
|
| 960 |
const tasksUrl = buildVersionedUrl(
|
| 961 |
repoId,
|
|
@@ -966,7 +818,7 @@ async function loadEpisodeDataV3(
|
|
| 966 |
const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
|
| 967 |
|
| 968 |
if (tasksData.length > 0) {
|
| 969 |
-
const taskIndexNum = bigIntToNumber(
|
| 970 |
|
| 971 |
if (taskIndexNum >= 0) {
|
| 972 |
// lerobot writes tasks.parquet from a DataFrame with the task
|
|
@@ -992,7 +844,7 @@ async function loadEpisodeDataV3(
|
|
| 992 |
flatChartData,
|
| 993 |
ignoredColumns,
|
| 994 |
task,
|
| 995 |
-
languageAtoms: extractLanguageAtoms(
|
| 996 |
frameTimestamps,
|
| 997 |
};
|
| 998 |
} catch {
|
|
@@ -1086,191 +938,93 @@ export function extractLanguageAtoms(
|
|
| 1086 |
return atoms;
|
| 1087 |
}
|
| 1088 |
|
| 1089 |
-
/
|
| 1090 |
-
|
| 1091 |
-
|
| 1092 |
-
|
| 1093 |
-
|
| 1094 |
-
|
| 1095 |
-
|
| 1096 |
-
|
| 1097 |
-
|
| 1098 |
-
|
| 1099 |
-
|
| 1100 |
-
|
| 1101 |
-
|
| 1102 |
-
|
| 1103 |
-
|
| 1104 |
-
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
|
| 1108 |
-
|
| 1109 |
-
|
| 1110 |
-
|
| 1111 |
-
|
| 1112 |
-
|
| 1113 |
-
|
| 1114 |
-
|
| 1115 |
-
|
| 1116 |
-
|
| 1117 |
-
|
| 1118 |
-
|
| 1119 |
-
|
| 1120 |
-
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 1125 |
-
|
| 1126 |
-
currentIndex++;
|
| 1127 |
-
}
|
| 1128 |
-
});
|
| 1129 |
-
|
| 1130 |
-
// Columns to exclude from charts (note: 'task' is intentionally not excluded as we want to access it)
|
| 1131 |
-
const excludedColumns = EXCLUDED_COLUMNS.V3 as readonly string[];
|
| 1132 |
-
|
| 1133 |
-
// Create columns structure similar to V2.1 for proper hierarchical naming
|
| 1134 |
-
const columns: ColumnDef[] = Object.entries(info.features)
|
| 1135 |
-
.filter(
|
| 1136 |
-
([key, value]) =>
|
| 1137 |
-
["float32", "int32"].includes(value.dtype) &&
|
| 1138 |
-
value.shape.length === 1 &&
|
| 1139 |
-
!excludedColumns.includes(key),
|
| 1140 |
-
)
|
| 1141 |
-
.map(([key, feature]) => {
|
| 1142 |
-
let column_names: unknown = feature.names;
|
| 1143 |
-
while (typeof column_names === "object" && column_names !== null) {
|
| 1144 |
-
if (Array.isArray(column_names)) break;
|
| 1145 |
-
column_names = Object.values(column_names)[0];
|
| 1146 |
-
}
|
| 1147 |
-
return {
|
| 1148 |
-
key,
|
| 1149 |
-
value: Array.isArray(column_names)
|
| 1150 |
-
? column_names.map(
|
| 1151 |
-
(name: string) => `${key}${SERIES_NAME_DELIMITER}${name}`,
|
| 1152 |
-
)
|
| 1153 |
-
: Array.from(
|
| 1154 |
-
{ length: feature.shape[0] || 1 },
|
| 1155 |
-
(_, i) => `${key}${CHART_CONFIG.SERIES_NAME_DELIMITER}${i}`,
|
| 1156 |
-
),
|
| 1157 |
-
};
|
| 1158 |
-
});
|
| 1159 |
-
|
| 1160 |
-
// First, extract all series from the first data row to understand the structure
|
| 1161 |
-
if (episodeData.length > 0) {
|
| 1162 |
-
const firstRow = episodeData[0];
|
| 1163 |
-
const allKeys: string[] = [];
|
| 1164 |
-
|
| 1165 |
-
Object.entries(firstRow || {}).forEach(([key, value]) => {
|
| 1166 |
-
if (key === "timestamp") return; // Skip timestamp, we'll add it separately
|
| 1167 |
-
|
| 1168 |
-
// Map numeric key to feature name if available
|
| 1169 |
-
const featureName = v3IndexToFeatureMap[key] || key;
|
| 1170 |
-
|
| 1171 |
-
// Skip if feature doesn't exist in dataset
|
| 1172 |
-
if (!info.features[featureName]) return;
|
| 1173 |
|
| 1174 |
-
|
| 1175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1176 |
|
| 1177 |
-
|
| 1178 |
-
|
| 1179 |
-
|
| 1180 |
-
|
| 1181 |
-
|
| 1182 |
-
|
| 1183 |
-
|
| 1184 |
-
|
| 1185 |
-
|
| 1186 |
-
|
| 1187 |
-
|
| 1188 |
-
|
| 1189 |
-
|
| 1190 |
-
|
| 1191 |
-
|
| 1192 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1193 |
});
|
| 1194 |
-
|
| 1195 |
-
seriesNames = ["timestamp", ...allKeys];
|
| 1196 |
-
} else {
|
| 1197 |
-
// Fallback to column-based approach like V2.1
|
| 1198 |
-
seriesNames = ["timestamp", ...columns.map(({ value }) => value).flat()];
|
| 1199 |
}
|
| 1200 |
|
| 1201 |
-
const
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
// Use actual video segment duration if available
|
| 1209 |
-
videoDuration =
|
| 1210 |
-
(episodeMetadata.video_to_timestamp || 30) -
|
| 1211 |
-
(episodeMetadata.video_from_timestamp || 0);
|
| 1212 |
-
}
|
| 1213 |
-
obj["timestamp"] =
|
| 1214 |
-
(index / Math.max(episodeData.length - 1, 1)) * videoDuration;
|
| 1215 |
-
|
| 1216 |
-
// Add all data columns using hierarchical naming
|
| 1217 |
-
if (row && typeof row === "object") {
|
| 1218 |
-
Object.entries(row).forEach(([key, value]) => {
|
| 1219 |
-
if (key === "timestamp") {
|
| 1220 |
-
// Timestamp is already handled above
|
| 1221 |
-
return;
|
| 1222 |
-
}
|
| 1223 |
-
|
| 1224 |
-
// Map numeric key to feature name if available
|
| 1225 |
-
const featureName = v3IndexToFeatureMap[key] || key;
|
| 1226 |
-
|
| 1227 |
-
// Skip if feature doesn't exist in dataset
|
| 1228 |
-
if (!info.features[featureName]) return;
|
| 1229 |
-
|
| 1230 |
-
// Skip excluded columns
|
| 1231 |
-
if (excludedColumns.includes(featureName)) return;
|
| 1232 |
-
|
| 1233 |
-
// Find the matching column definition to get proper series names
|
| 1234 |
-
const columnDef = columns.find((col) => col.key === featureName);
|
| 1235 |
-
|
| 1236 |
-
if (Array.isArray(value) && columnDef) {
|
| 1237 |
-
// For array values like observation.state and action, use proper hierarchical naming
|
| 1238 |
-
value.forEach((val, idx) => {
|
| 1239 |
-
if (idx < columnDef.value.length) {
|
| 1240 |
-
const seriesName = columnDef.value[idx];
|
| 1241 |
-
obj[seriesName] = typeof val === "number" ? val : Number(val);
|
| 1242 |
-
}
|
| 1243 |
-
});
|
| 1244 |
-
} else if (typeof value === "number" && !isNaN(value)) {
|
| 1245 |
-
obj[featureName] = value;
|
| 1246 |
-
} else if (typeof value === "bigint") {
|
| 1247 |
-
obj[featureName] = Number(value);
|
| 1248 |
-
} else if (typeof value === "boolean") {
|
| 1249 |
-
// Convert boolean to number for charts
|
| 1250 |
-
obj[featureName] = value ? 1 : 0;
|
| 1251 |
-
}
|
| 1252 |
-
});
|
| 1253 |
-
}
|
| 1254 |
-
|
| 1255 |
-
return obj;
|
| 1256 |
});
|
| 1257 |
|
| 1258 |
-
|
| 1259 |
-
const
|
| 1260 |
-
|
| 1261 |
-
|
| 1262 |
-
|
| 1263 |
-
|
| 1264 |
-
)
|
| 1265 |
-
.map(([key]) => key),
|
| 1266 |
-
...excludedColumns, // Also include the manually excluded columns
|
| 1267 |
-
];
|
| 1268 |
-
|
| 1269 |
-
// Process chart data into organized groups using utility function
|
| 1270 |
-
const chartGroups = processChartDataGroups(seriesNames, chartData);
|
| 1271 |
-
|
| 1272 |
-
const chartDataGroups = chartGroups.map((group) =>
|
| 1273 |
-
chartData.map((row) => {
|
| 1274 |
const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
|
| 1275 |
// Ensure timestamp is always a number at the top level
|
| 1276 |
return {
|
|
@@ -1281,7 +1035,7 @@ function processEpisodeDataForCharts(
|
|
| 1281 |
}),
|
| 1282 |
);
|
| 1283 |
|
| 1284 |
-
return { chartDataGroups, flatChartData
|
| 1285 |
}
|
| 1286 |
|
| 1287 |
// βββ Stats computation βββββββββββββββββββββββββββββββββββββββββββ
|
|
@@ -2252,7 +2006,7 @@ export async function loadEpisodeFlatChartData(
|
|
| 2252 |
version,
|
| 2253 |
info,
|
| 2254 |
toEpisodeMetadataV3(episode),
|
| 2255 |
-
episode
|
| 2256 |
);
|
| 2257 |
return flatChartData;
|
| 2258 |
}
|
|
|
|
| 10 |
buildVersionedUrl,
|
| 11 |
getDatasetStats,
|
| 12 |
} from "@/utils/versionUtils";
|
| 13 |
+
import { CHART_CONFIG, EXCLUDED_COLUMNS } from "@/utils/constants";
|
| 14 |
import {
|
| 15 |
processChartDataGroups,
|
| 16 |
groupRowBySuffix,
|
|
|
|
| 23 |
} from "@/utils/colormaps";
|
| 24 |
import type { VideoInfo, AdjacentEpisodeVideos } from "@/types";
|
| 25 |
import { LeRobotDataset, parseInfo } from "@huggingface/lerobot";
|
| 26 |
+
import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
|
| 27 |
import { authHeaders } from "@/utils/auth";
|
| 28 |
|
| 29 |
const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
|
|
|
|
| 197 |
[key: string]: string | number | string[] | undefined;
|
| 198 |
};
|
| 199 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 200 |
function parsePositiveIntEnv(
|
| 201 |
value: string | undefined,
|
| 202 |
fallback: number,
|
|
|
|
| 521 |
info: DatasetMetadata,
|
| 522 |
episodeId: number,
|
| 523 |
): Promise<EpisodeData> {
|
|
|
|
|
|
|
|
|
|
| 524 |
const datasetInfo: DatasetDisplayInfo = {
|
| 525 |
repoId,
|
| 526 |
total_frames: info.total_frames,
|
|
|
|
| 545 |
.map((x) => parseInt(x.trim(), 10))
|
| 546 |
.filter((x) => !isNaN(x));
|
| 547 |
|
| 548 |
+
const leRobotEpisode = await loadLeRobotEpisode(repoId, episodeId);
|
| 549 |
const videosInfo =
|
| 550 |
+
info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 551 |
|
| 552 |
+
const { frames, textRows } = await loadEpisodeFrames(repoId, leRobotEpisode);
|
| 553 |
+
const frameTimestamps = sortedTimestamps(frames);
|
| 554 |
+
const languageAtoms = extractLanguageAtoms(textRows);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 555 |
|
| 556 |
// Extract task from language_instruction fields, task field, or tasks.jsonl
|
| 557 |
let task: string | undefined;
|
| 558 |
|
| 559 |
+
if (textRows.length > 0) {
|
| 560 |
+
const firstRow = textRows[0];
|
| 561 |
const languageInstructions: string[] = [];
|
| 562 |
|
| 563 |
if (typeof firstRow.language_instruction === "string") {
|
|
|
|
| 579 |
}
|
| 580 |
}
|
| 581 |
|
| 582 |
+
if (!task && textRows.length > 0 && typeof textRows[0].task === "string") {
|
| 583 |
+
task = textRows[0].task;
|
| 584 |
}
|
| 585 |
|
| 586 |
+
if (!task && textRows.length > 0) {
|
| 587 |
try {
|
| 588 |
const tasksUrl = buildVersionedUrl(repoId, version, "meta/tasks.jsonl");
|
| 589 |
const tasksResponse = await fetch(tasksUrl, { cache: "no-store" });
|
|
|
|
| 596 |
.map((line) => JSON.parse(line));
|
| 597 |
|
| 598 |
if (tasksData && tasksData.length > 0) {
|
| 599 |
+
const taskIndex = textRows[0].task_index;
|
| 600 |
const taskIndexNum =
|
| 601 |
typeof taskIndex === "bigint" ? Number(taskIndex) : taskIndex;
|
| 602 |
const taskData = tasksData.find(
|
|
|
|
| 612 |
}
|
| 613 |
}
|
| 614 |
|
| 615 |
+
const { chartDataGroups, flatChartData } = frames
|
| 616 |
+
? chartDataFromFrames(
|
| 617 |
+
frames,
|
| 618 |
+
info,
|
| 619 |
+
EXCLUDED_COLUMNS.V2,
|
| 620 |
+
(frameIndex) => frames.timestamps[frameIndex],
|
| 621 |
+
)
|
| 622 |
+
: { chartDataGroups: [], flatChartData: [] };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 623 |
|
| 624 |
// List of columns that are ignored (e.g., 2D or 3D data)
|
| 625 |
const ignoredColumns = Object.entries(info.features)
|
|
|
|
| 629 |
)
|
| 630 |
.map(([key]) => key);
|
| 631 |
|
|
|
|
|
|
|
|
|
|
| 632 |
const duration =
|
| 633 |
+
flatChartData.length > 0
|
| 634 |
+
? flatChartData[flatChartData.length - 1].timestamp
|
| 635 |
: 0;
|
| 636 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 637 |
return {
|
| 638 |
datasetInfo,
|
| 639 |
episodeId,
|
| 640 |
videosInfo,
|
| 641 |
chartDataGroups,
|
| 642 |
+
flatChartData,
|
| 643 |
episodes,
|
| 644 |
ignoredColumns,
|
| 645 |
duration,
|
|
|
|
| 687 |
version,
|
| 688 |
info,
|
| 689 |
episodeMetadata,
|
| 690 |
+
leRobotEpisode,
|
| 691 |
);
|
| 692 |
|
| 693 |
const duration = episodeMetadata.length
|
|
|
|
| 715 |
version: string,
|
| 716 |
info: DatasetMetadata,
|
| 717 |
episodeMetadata: EpisodeMetadataV3,
|
| 718 |
+
episode: LeRobotEpisode,
|
| 719 |
): Promise<{
|
| 720 |
chartDataGroups: ChartRow[][];
|
| 721 |
flatChartData: Record<string, number>[];
|
|
|
|
| 725 |
frameTimestamps?: number[];
|
| 726 |
}> {
|
| 727 |
try {
|
| 728 |
+
const { frames, textRows } = await loadEpisodeFrames(repoId, episode);
|
| 729 |
+
const frameTimestamps = sortedTimestamps(frames);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 730 |
|
| 731 |
+
if (!frames) {
|
|
|
|
|
|
|
| 732 |
return {
|
| 733 |
chartDataGroups: [],
|
| 734 |
flatChartData: [],
|
| 735 |
ignoredColumns: [],
|
| 736 |
task: undefined,
|
| 737 |
+
languageAtoms: extractLanguageAtoms(textRows),
|
| 738 |
frameTimestamps,
|
| 739 |
};
|
| 740 |
}
|
| 741 |
|
| 742 |
+
/// Stretched over the video segment rather than read from `timestamp`, so the chart cursor
|
| 743 |
+
/// stays in step with the player.
|
| 744 |
+
const videoDuration =
|
| 745 |
+
(episodeMetadata.video_to_timestamp || 30) -
|
| 746 |
+
(episodeMetadata.video_from_timestamp || 0);
|
| 747 |
+
const { chartDataGroups, flatChartData } = chartDataFromFrames(
|
| 748 |
+
frames,
|
| 749 |
+
info,
|
| 750 |
+
EXCLUDED_COLUMNS.V3,
|
| 751 |
+
(_, position, count) =>
|
| 752 |
+
(position / Math.max(count - 1, 1)) * videoDuration,
|
| 753 |
+
);
|
| 754 |
+
|
| 755 |
+
// List of columns that are ignored (now we handle 2D data by flattening)
|
| 756 |
+
const ignoredColumns = [
|
| 757 |
+
...Object.entries(info.features)
|
| 758 |
+
.filter(
|
| 759 |
+
([, value]) =>
|
| 760 |
+
["float32", "int32"].includes(value.dtype) &&
|
| 761 |
+
value.shape.length > 2, // Only ignore 3D+ data
|
| 762 |
+
)
|
| 763 |
+
.map(([key]) => key),
|
| 764 |
+
...EXCLUDED_COLUMNS.V3,
|
| 765 |
+
];
|
| 766 |
|
| 767 |
// Prefer the authoritative `tasks` list on the episode's own metadata
|
| 768 |
// (v3.0 stores it as list[str] β see lerobot dataset_metadata.save_episode).
|
|
|
|
| 772 |
}
|
| 773 |
|
| 774 |
// Fall back to per-frame language_instruction fields
|
| 775 |
+
if (!task && textRows.length > 0) {
|
| 776 |
const languageInstructions: string[] = [];
|
| 777 |
|
| 778 |
const extractInstructions = (row: Record<string, unknown>) => {
|
|
|
|
| 788 |
}
|
| 789 |
};
|
| 790 |
|
| 791 |
+
extractInstructions(textRows[0]);
|
| 792 |
|
| 793 |
// If no instructions in first row, check middle and last rows
|
| 794 |
+
if (languageInstructions.length === 0 && textRows.length > 1) {
|
| 795 |
for (const idx of [
|
| 796 |
+
Math.floor(textRows.length / 2),
|
| 797 |
+
textRows.length - 1,
|
| 798 |
]) {
|
| 799 |
+
extractInstructions(textRows[idx]);
|
| 800 |
if (languageInstructions.length > 0) break;
|
| 801 |
}
|
| 802 |
}
|
|
|
|
| 807 |
}
|
| 808 |
|
| 809 |
// Fall back to tasks metadata parquet
|
| 810 |
+
if (!task && textRows.length > 0) {
|
| 811 |
try {
|
| 812 |
const tasksUrl = buildVersionedUrl(
|
| 813 |
repoId,
|
|
|
|
| 818 |
const tasksData = await readParquetAsObjects(tasksArrayBuffer, []);
|
| 819 |
|
| 820 |
if (tasksData.length > 0) {
|
| 821 |
+
const taskIndexNum = bigIntToNumber(textRows[0].task_index, -1);
|
| 822 |
|
| 823 |
if (taskIndexNum >= 0) {
|
| 824 |
// lerobot writes tasks.parquet from a DataFrame with the task
|
|
|
|
| 844 |
flatChartData,
|
| 845 |
ignoredColumns,
|
| 846 |
task,
|
| 847 |
+
languageAtoms: extractLanguageAtoms(textRows),
|
| 848 |
frameTimestamps,
|
| 849 |
};
|
| 850 |
} catch {
|
|
|
|
| 938 |
return atoms;
|
| 939 |
}
|
| 940 |
|
| 941 |
+
/**
|
| 942 |
+
* Columns `frames()` leaves out that the episode view still needs: the task, and the v3.1 language
|
| 943 |
+
* atoms (lerobot#3467), which lerobot can also write onto v2.x datasets. hyparquet skips requested
|
| 944 |
+
* columns that are not in the file, so un-annotated datasets cost nothing extra.
|
| 945 |
+
*/
|
| 946 |
+
const TEXT_COLUMNS = [
|
| 947 |
+
"timestamp",
|
| 948 |
+
"task",
|
| 949 |
+
"task_index",
|
| 950 |
+
"language_instruction",
|
| 951 |
+
"language_instruction_2",
|
| 952 |
+
"language_instruction_3",
|
| 953 |
+
"language_persistent",
|
| 954 |
+
"language_events",
|
| 955 |
+
];
|
| 956 |
+
|
| 957 |
+
/** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
|
| 958 |
+
async function loadEpisodeFrames(
|
| 959 |
+
repoId: string,
|
| 960 |
+
episode: LeRobotEpisode,
|
| 961 |
+
): Promise<{
|
| 962 |
+
frames: LeRobotFrames | undefined;
|
| 963 |
+
textRows: Record<string, unknown>[];
|
| 964 |
+
}> {
|
| 965 |
+
const data = episode.data;
|
| 966 |
+
if (!data) return { frames: undefined, textRows: [] };
|
| 967 |
+
const [frames, textRows] = await Promise.all([
|
| 968 |
+
leRobotDataset(repoId).frames(episode),
|
| 969 |
+
fetchParquetFile(data.url).then((file) =>
|
| 970 |
+
readParquetAsObjects(file, TEXT_COLUMNS, {
|
| 971 |
+
rowStart: data.fromRow,
|
| 972 |
+
rowEnd: data.toRow,
|
| 973 |
+
}),
|
| 974 |
+
),
|
| 975 |
+
]);
|
| 976 |
+
return { frames, textRows };
|
| 977 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 978 |
|
| 979 |
+
/** Source-frame timestamps, sorted, for the annotations editor to snap to. */
|
| 980 |
+
function sortedTimestamps(frames: LeRobotFrames | undefined): number[] {
|
| 981 |
+
return (frames?.timestamps ?? [])
|
| 982 |
+
.filter((t) => Number.isFinite(t))
|
| 983 |
+
.sort((a, b) => a - b);
|
| 984 |
+
}
|
| 985 |
|
| 986 |
+
/**
|
| 987 |
+
* Chart rows for an episode, sampled down to MAX_EPISODE_POINTS. An array feature gets one series
|
| 988 |
+
* per component, named from `info.json` when it lists one name per component and by index otherwise;
|
| 989 |
+
* a single unnamed component (a reward, a success flag) keeps its bare feature key.
|
| 990 |
+
*/
|
| 991 |
+
function chartDataFromFrames(
|
| 992 |
+
frames: LeRobotFrames,
|
| 993 |
+
info: DatasetMetadata,
|
| 994 |
+
excludedColumns: readonly string[],
|
| 995 |
+
timestampAt: (frameIndex: number, position: number, count: number) => number,
|
| 996 |
+
): { chartDataGroups: ChartRow[][]; flatChartData: Record<string, number>[] } {
|
| 997 |
+
const series: [string, number[]][] = [];
|
| 998 |
+
for (const [key, components] of Object.entries(frames.series)) {
|
| 999 |
+
if (!info.features[key] || excludedColumns.includes(key)) continue;
|
| 1000 |
+
const names =
|
| 1001 |
+
frames.names[key]?.length === components.length
|
| 1002 |
+
? frames.names[key]
|
| 1003 |
+
: undefined;
|
| 1004 |
+
components.forEach((values, i) => {
|
| 1005 |
+
const suffix = names?.[i] ?? (components.length > 1 ? i : undefined);
|
| 1006 |
+
series.push([
|
| 1007 |
+
suffix === undefined ? key : `${key}${SERIES_NAME_DELIMITER}${suffix}`,
|
| 1008 |
+
values,
|
| 1009 |
+
]);
|
| 1010 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1011 |
}
|
| 1012 |
|
| 1013 |
+
const sampled = evenlySampleIndices(frames.length, MAX_EPISODE_POINTS);
|
| 1014 |
+
const flatChartData = sampled.map((frameIndex, position) => {
|
| 1015 |
+
const row: Record<string, number> = {
|
| 1016 |
+
timestamp: timestampAt(frameIndex, position, sampled.length),
|
| 1017 |
+
};
|
| 1018 |
+
for (const [name, values] of series) row[name] = values[frameIndex];
|
| 1019 |
+
return row;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1020 |
});
|
| 1021 |
|
| 1022 |
+
const seriesNames = ["timestamp", ...series.map(([name]) => name)];
|
| 1023 |
+
const chartDataGroups = processChartDataGroups(
|
| 1024 |
+
seriesNames,
|
| 1025 |
+
flatChartData,
|
| 1026 |
+
).map((group) =>
|
| 1027 |
+
flatChartData.map((row) => {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1028 |
const grouped = groupRowBySuffix(pick(row, [...group, "timestamp"]));
|
| 1029 |
// Ensure timestamp is always a number at the top level
|
| 1030 |
return {
|
|
|
|
| 1035 |
}),
|
| 1036 |
);
|
| 1037 |
|
| 1038 |
+
return { chartDataGroups, flatChartData };
|
| 1039 |
}
|
| 1040 |
|
| 1041 |
// βββ Stats computation βββββββββββββββββββββββββββββββββββββββββββ
|
|
|
|
| 2006 |
version,
|
| 2007 |
info,
|
| 2008 |
toEpisodeMetadataV3(episode),
|
| 2009 |
+
episode,
|
| 2010 |
);
|
| 2011 |
return flatChartData;
|
| 2012 |
}
|