Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
Sync from huggingface/lerobot-dataset-visualizer via hub-sync
Browse files- AGENTS.md +3 -3
- README.md +24 -0
- bun.lock +23 -2
- package.json +4 -1
- src/app/[org]/[dataset]/[episode]/episode-viewer.tsx +60 -5
- src/app/[org]/[dataset]/[episode]/fetch-data.ts +80 -10
- src/components/__tests__/simple-videos-player.test.tsx +163 -0
- src/components/hand-overlay-canvas.tsx +156 -0
- src/components/hand-replay.tsx +376 -0
- src/components/simple-videos-player.tsx +54 -8
- src/components/urdf-viewer.tsx +106 -200
- src/lib/__tests__/hand-tracking.test.ts +317 -0
- src/lib/hand-tracking.ts +447 -0
- src/utils/__tests__/parquetUtils.test.ts +46 -0
- src/utils/parquetUtils.ts +36 -0
- tsconfig.test.json +2 -1
AGENTS.md
CHANGED
|
@@ -59,7 +59,7 @@ Locating an episode (its video URLs and offsets, its data file and row range) an
|
|
| 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 |
|
|
@@ -131,9 +131,9 @@ Reserved/bookkeeping columns from lerobot — see `EXCLUDED_COLUMNS` in `src/uti
|
|
| 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 |
-
-
|
|
|
|
| 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
|
|
|
|
| 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. hyparquet decodes a nested column for its whole row group, so a nested language column is left out past `MAX_NESTED_TEXT_VALUES` values.
|
| 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 |
|
|
|
|
| 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 |
+
- SO-100/SO-101 and OpenArm are drawn from the USDs in the dataset [`lerobot/robot-usds`](https://huggingface.co/datasets/lerobot/robot-usds), loaded with `@huggingface/usd-web-viewer`. Their URDFs share the USDs' joint and link names, link frames and zero pose, and only move them: `attachUsdMeshes` puts each USD mesh under the URDF link its prim path names. G1 still loads its URDF's STL meshes.
|
| 135 |
+
- **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.
|
| 136 |
- **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`.
|
|
|
|
| 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
|
README.md
CHANGED
|
@@ -37,10 +37,33 @@ This tool is designed to help robotics researchers and practitioners quickly ins
|
|
| 37 |
- **Action Insights Panel:** Data-driven analysis tools to guide training configuration — includes autocorrelation, state-action alignment, speed distribution, and cross-episode variance heatmap.
|
| 38 |
- **Filtering Panel:** Identify and flag problematic episodes (low movement, jerky motion, outlier length) for removal. Exports flagged episode IDs as a ready-to-run LeRobot CLI command.
|
| 39 |
- **3D URDF Viewer:** Visualize robot joint poses frame-by-frame in an interactive 3D scene, with end-effector trail rendering. Supports SO-100, SO-101, and OpenArm bimanual robots.
|
|
|
|
| 40 |
- **Annotations Panel:** Hand-edit the v3.1 language schema (`language_persistent` + `language_events`) — subtask, plan, memory, interjection + paired speech, and VQA atoms with bounding-box / keypoint / count / attribute / spatial answers. VQA bboxes and keypoints render as overlays on the video player; drag or click on a camera to draw new ones. Backed by an optional FastAPI service (in `backend/`) for parquet rewrites and HF Hub push.
|
| 41 |
- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load.
|
| 42 |
- **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
## Technologies Used
|
| 45 |
|
| 46 |
- **Next.js** (App Router)
|
|
@@ -48,6 +71,7 @@ This tool is designed to help robotics researchers and practitioners quickly ins
|
|
| 48 |
- **Recharts** (for data visualization)
|
| 49 |
- **Three.js** + **@react-three/fiber** + **@react-three/drei** (for 3D URDF visualization)
|
| 50 |
- **urdf-loader** (for parsing URDF robot models)
|
|
|
|
| 51 |
- **hyparquet** (for reading Parquet files)
|
| 52 |
- **Tailwind CSS** (styling)
|
| 53 |
|
|
|
|
| 37 |
- **Action Insights Panel:** Data-driven analysis tools to guide training configuration — includes autocorrelation, state-action alignment, speed distribution, and cross-episode variance heatmap.
|
| 38 |
- **Filtering Panel:** Identify and flag problematic episodes (low movement, jerky motion, outlier length) for removal. Exports flagged episode IDs as a ready-to-run LeRobot CLI command.
|
| 39 |
- **3D URDF Viewer:** Visualize robot joint poses frame-by-frame in an interactive 3D scene, with end-effector trail rendering. Supports SO-100, SO-101, and OpenArm bimanual robots.
|
| 40 |
+
- **3D Hands:** Automatically detects native SOMA156 hand annotations. Shows wrists, 21-joint hand skeletons, wrist trails and the saved camera trajectory alongside a synchronized camera overlay, with a Follow camera control.
|
| 41 |
- **Annotations Panel:** Hand-edit the v3.1 language schema (`language_persistent` + `language_events`) — subtask, plan, memory, interjection + paired speech, and VQA atoms with bounding-box / keypoint / count / attribute / spatial answers. VQA bboxes and keypoints render as overlays on the video player; drag or click on a camera to draw new ones. Backed by an optional FastAPI service (in `backend/`) for parquet rewrites and HF Hub push.
|
| 42 |
- **Efficient Data Loading:** Uses parquet and JSON loading for large dataset support, with pagination, chunking, and lazy-loaded panels for fast initial load.
|
| 43 |
- **Responsive UI:** Built with React, Next.js, and Tailwind CSS for a fast, modern user experience.
|
| 44 |
|
| 45 |
+
## Hand annotations
|
| 46 |
+
|
| 47 |
+
Choose **Advanced → 3D Hands** (below **Annotations**) or open an annotated
|
| 48 |
+
episode with `?tab=hands`. Detection requires the 156D
|
| 49 |
+
`action` plus `annotation.hand_joints_camera` and `annotation.hand_joints_world`
|
| 50 |
+
(126 values each), `annotation.hand_action_valid`, `annotation.hand_observed`
|
| 51 |
+
(left/right) and `annotation.hand_reference_frame`. No model weights or meshes
|
| 52 |
+
are required. Missing or masked poses remain gaps in both views.
|
| 53 |
+
|
| 54 |
+
When `annotation.camera_world_from_optical` contains saved rigid transforms,
|
| 55 |
+
the viewer reconstructs recording-wide hand positions from camera-space joints,
|
| 56 |
+
anchored to the first saved camera pose. The camera path also breaks at missing
|
| 57 |
+
transforms. Without them, it displays the stored segment-start FLU coordinates
|
| 58 |
+
and breaks wrist trails at reference resets. Positions are in meters.
|
| 59 |
+
|
| 60 |
+
The overlay uses the source camera and per-source-episode calibration saved in
|
| 61 |
+
`annotation_studio/run.json`, resolving exported episodes through
|
| 62 |
+
`source_episode_map`; a video feature's `info["camera.intrinsics"]` is also
|
| 63 |
+
supported. Pinhole and OpenCV fisheye distortion are projected onto the original
|
| 64 |
+
video at its saved resolution. Unknown cameras, missing calibration and mismatched
|
| 65 |
+
video dimensions disable the overlay. Estimated calibration is labeled.
|
| 66 |
+
|
| 67 |
## Technologies Used
|
| 68 |
|
| 69 |
- **Next.js** (App Router)
|
|
|
|
| 71 |
- **Recharts** (for data visualization)
|
| 72 |
- **Three.js** + **@react-three/fiber** + **@react-three/drei** (for 3D URDF visualization)
|
| 73 |
- **urdf-loader** (for parsing URDF robot models)
|
| 74 |
+
- **@huggingface/usd-web-viewer** (for the robot USD models in [lerobot/robot-usds](https://huggingface.co/datasets/lerobot/robot-usds))
|
| 75 |
- **hyparquet** (for reading Parquet files)
|
| 76 |
- **Tailwind CSS** (styling)
|
| 77 |
|
bun.lock
CHANGED
|
@@ -6,9 +6,11 @@
|
|
| 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",
|
| 13 |
"next": "15.3.6",
|
| 14 |
"react": "^19.0.0",
|
|
@@ -28,6 +30,7 @@
|
|
| 28 |
"@types/three": "^0.182.0",
|
| 29 |
"eslint": "^9",
|
| 30 |
"eslint-config-next": "15.3.1",
|
|
|
|
| 31 |
"prettier": "3.8.2",
|
| 32 |
"tailwindcss": "^4",
|
| 33 |
"typescript": "^5",
|
|
@@ -67,10 +70,12 @@
|
|
| 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 |
|
|
|
|
|
|
|
| 74 |
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
| 75 |
|
| 76 |
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
|
@@ -263,6 +268,10 @@
|
|
| 263 |
|
| 264 |
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
| 265 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 266 |
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
| 267 |
|
| 268 |
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="],
|
|
@@ -379,6 +388,8 @@
|
|
| 379 |
|
| 380 |
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
| 381 |
|
|
|
|
|
|
|
| 382 |
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
| 383 |
|
| 384 |
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
|
@@ -471,6 +482,8 @@
|
|
| 471 |
|
| 472 |
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
| 473 |
|
|
|
|
|
|
|
| 474 |
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
| 475 |
|
| 476 |
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
|
@@ -557,6 +570,8 @@
|
|
| 557 |
|
| 558 |
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
| 559 |
|
|
|
|
|
|
|
| 560 |
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
| 561 |
|
| 562 |
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
|
@@ -579,6 +594,8 @@
|
|
| 579 |
|
| 580 |
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
| 581 |
|
|
|
|
|
|
|
| 582 |
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
| 583 |
|
| 584 |
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
|
@@ -981,6 +998,8 @@
|
|
| 981 |
|
| 982 |
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
|
| 983 |
|
|
|
|
|
|
|
| 984 |
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
| 985 |
|
| 986 |
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
|
@@ -993,6 +1012,8 @@
|
|
| 993 |
|
| 994 |
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
| 995 |
|
|
|
|
|
|
|
| 996 |
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
| 997 |
|
| 998 |
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
|
|
|
| 6 |
"name": "lerobot-viewer",
|
| 7 |
"dependencies": {
|
| 8 |
"@huggingface/hub": "^2.11.0",
|
| 9 |
+
"@huggingface/lerobot": "^0.0.13",
|
| 10 |
+
"@huggingface/usd-web-viewer": "0.2.0",
|
| 11 |
"@react-three/drei": "^10.7.7",
|
| 12 |
"@react-three/fiber": "^9.5.0",
|
| 13 |
+
"fzstd": "0.1.1",
|
| 14 |
"hyparquet": "^1.12.1",
|
| 15 |
"next": "15.3.6",
|
| 16 |
"react": "^19.0.0",
|
|
|
|
| 30 |
"@types/three": "^0.182.0",
|
| 31 |
"eslint": "^9",
|
| 32 |
"eslint-config-next": "15.3.1",
|
| 33 |
+
"happy-dom": "21.0.4",
|
| 34 |
"prettier": "3.8.2",
|
| 35 |
"tailwindcss": "^4",
|
| 36 |
"typescript": "^5",
|
|
|
|
| 70 |
|
| 71 |
"@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=="],
|
| 72 |
|
| 73 |
+
"@huggingface/lerobot": ["@huggingface/lerobot@0.0.13", "", { "dependencies": { "fzstd": "0.1.1", "hyparquet": "^1.31.2" } }, "sha512-FWjICnB9ndm4eRWH52xAybT4bDhK+JQZCMJrF3ivVYtG/b8H81I+vcZ5Yw22NNCQVZ2Ib7FgOLYo0rmyxJrUoQ=="],
|
| 74 |
|
| 75 |
"@huggingface/tasks": ["@huggingface/tasks@0.19.90", "", {}, "sha512-nfV9luJbvwGQ/5oKXkKhCV9h4X7mwh1YaGG3ORd6UMLDSwr1OFSSatcBX0O9OtBtmNK19aGSjbLFqqgcIR6+IA=="],
|
| 76 |
|
| 77 |
+
"@huggingface/usd-web-viewer": ["@huggingface/usd-web-viewer@0.2.0", "", { "peerDependencies": { "@types/three": ">=0.162.0 <0.187.0", "three": ">=0.162.0 <0.187.0" }, "optionalPeers": ["@types/three"] }, "sha512-2VpC0Gwbq0n7cl4/Lb5YpxQeUdFg6rvM2ZpIkU/Hc5eYaM5nsHqcDgwy2RVaZ/DOJcgDpmwkxWAlrVXhq3rpqg=="],
|
| 78 |
+
|
| 79 |
"@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="],
|
| 80 |
|
| 81 |
"@humanfs/node": ["@humanfs/node@0.16.7", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.4.0" } }, "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ=="],
|
|
|
|
| 268 |
|
| 269 |
"@types/webxr": ["@types/webxr@0.5.24", "", {}, "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg=="],
|
| 270 |
|
| 271 |
+
"@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="],
|
| 272 |
+
|
| 273 |
+
"@types/ws": ["@types/ws@8.18.2", "", { "dependencies": { "@types/node": "*" } }, "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w=="],
|
| 274 |
+
|
| 275 |
"@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.55.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.12.2", "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/type-utils": "8.55.0", "@typescript-eslint/utils": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "ignore": "^7.0.5", "natural-compare": "^1.4.0", "ts-api-utils": "^2.4.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.55.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-1y/MVSz0NglV1ijHC8OT49mPJ4qhPYjiK08YUQVbIOyu+5k862LKUHFkpKHWu//zmr7hDR2rhwUm6gnCGNmGBQ=="],
|
| 276 |
|
| 277 |
"@typescript-eslint/parser": ["@typescript-eslint/parser@8.55.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.55.0", "@typescript-eslint/types": "8.55.0", "@typescript-eslint/typescript-estree": "8.55.0", "@typescript-eslint/visitor-keys": "8.55.0", "debug": "^4.4.3" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <6.0.0" } }, "sha512-4z2nCSBfVIMnbuu8uinj+f0o4qOeggYJLbjpPHka3KH1om7e+H9yLKTYgksTaHcGco+NClhhY2vyO3HsMH1RGw=="],
|
|
|
|
| 388 |
|
| 389 |
"buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="],
|
| 390 |
|
| 391 |
+
"buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="],
|
| 392 |
+
|
| 393 |
"bun-types": ["bun-types@1.3.10", "", { "dependencies": { "@types/node": "*" } }, "sha512-tcpfCCl6XWo6nCVnpcVrxQ+9AYN1iqMIzgrSKYMB/fjLtV2eyAVEg7AxQJuCq/26R6HpKWykQXuSOq/21RYcbg=="],
|
| 394 |
|
| 395 |
"busboy": ["busboy@1.6.0", "", { "dependencies": { "streamsearch": "^1.1.0" } }, "sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA=="],
|
|
|
|
| 482 |
|
| 483 |
"enhanced-resolve": ["enhanced-resolve@5.19.0", "", { "dependencies": { "graceful-fs": "^4.2.4", "tapable": "^2.3.0" } }, "sha512-phv3E1Xl4tQOShqSte26C7Fl84EwUdZsyOuSSk9qtAGyyQs2s3jJzComh+Abf4g187lUUAvH+H26omrqia2aGg=="],
|
| 484 |
|
| 485 |
+
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
|
| 486 |
+
|
| 487 |
"es-abstract": ["es-abstract@1.24.1", "", { "dependencies": { "array-buffer-byte-length": "^1.0.2", "arraybuffer.prototype.slice": "^1.0.4", "available-typed-arrays": "^1.0.7", "call-bind": "^1.0.8", "call-bound": "^1.0.4", "data-view-buffer": "^1.0.2", "data-view-byte-length": "^1.0.2", "data-view-byte-offset": "^1.0.1", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "es-set-tostringtag": "^2.1.0", "es-to-primitive": "^1.3.0", "function.prototype.name": "^1.1.8", "get-intrinsic": "^1.3.0", "get-proto": "^1.0.1", "get-symbol-description": "^1.1.0", "globalthis": "^1.0.4", "gopd": "^1.2.0", "has-property-descriptors": "^1.0.2", "has-proto": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "internal-slot": "^1.1.0", "is-array-buffer": "^3.0.5", "is-callable": "^1.2.7", "is-data-view": "^1.0.2", "is-negative-zero": "^2.0.3", "is-regex": "^1.2.1", "is-set": "^2.0.3", "is-shared-array-buffer": "^1.0.4", "is-string": "^1.1.1", "is-typed-array": "^1.1.15", "is-weakref": "^1.1.1", "math-intrinsics": "^1.1.0", "object-inspect": "^1.13.4", "object-keys": "^1.1.1", "object.assign": "^4.1.7", "own-keys": "^1.0.1", "regexp.prototype.flags": "^1.5.4", "safe-array-concat": "^1.1.3", "safe-push-apply": "^1.0.0", "safe-regex-test": "^1.1.0", "set-proto": "^1.0.0", "stop-iteration-iterator": "^1.1.0", "string.prototype.trim": "^1.2.10", "string.prototype.trimend": "^1.0.9", "string.prototype.trimstart": "^1.0.8", "typed-array-buffer": "^1.0.3", "typed-array-byte-length": "^1.0.3", "typed-array-byte-offset": "^1.0.4", "typed-array-length": "^1.0.7", "unbox-primitive": "^1.1.0", "which-typed-array": "^1.1.19" } }, "sha512-zHXBLhP+QehSSbsS9Pt23Gg964240DPd6QCf8WpkqEXxQ7fhdZzYsocOr5u7apWonsS5EjZDmTF+/slGMyasvw=="],
|
| 488 |
|
| 489 |
"es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="],
|
|
|
|
| 570 |
|
| 571 |
"functions-have-names": ["functions-have-names@1.2.3", "", {}, "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ=="],
|
| 572 |
|
| 573 |
+
"fzstd": ["fzstd@0.1.1", "", {}, "sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA=="],
|
| 574 |
+
|
| 575 |
"generator-function": ["generator-function@2.0.1", "", {}, "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g=="],
|
| 576 |
|
| 577 |
"get-intrinsic": ["get-intrinsic@1.3.0", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.2", "es-define-property": "^1.0.1", "es-errors": "^1.3.0", "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", "get-proto": "^1.0.1", "gopd": "^1.2.0", "has-symbols": "^1.1.0", "hasown": "^2.0.2", "math-intrinsics": "^1.1.0" } }, "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ=="],
|
|
|
|
| 594 |
|
| 595 |
"graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
|
| 596 |
|
| 597 |
+
"happy-dom": ["happy-dom@21.0.4", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-tFm+GaLBgo6o0awHnvHaNoraVo27qJ+2tZEtZ+cIa08hXbeitrxAJajLI05hsUy/2/YTKcUjQYzHe5mCcraLBw=="],
|
| 598 |
+
|
| 599 |
"has-bigints": ["has-bigints@1.1.0", "", {}, "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg=="],
|
| 600 |
|
| 601 |
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
|
|
|
|
| 998 |
|
| 999 |
"webgl-sdf-generator": ["webgl-sdf-generator@1.1.1", "", {}, "sha512-9Z0JcMTFxeE+b2x1LJTdnaT8rT8aEp7MVxkNwoycNmJWwPdzoXzMh0BjJSh/AEFP+KPYZUli814h8bJZFIZ2jA=="],
|
| 1000 |
|
| 1001 |
+
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
|
| 1002 |
+
|
| 1003 |
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
|
| 1004 |
|
| 1005 |
"which-boxed-primitive": ["which-boxed-primitive@1.1.1", "", { "dependencies": { "is-bigint": "^1.1.0", "is-boolean-object": "^1.2.1", "is-number-object": "^1.1.1", "is-string": "^1.1.1", "is-symbol": "^1.1.1" } }, "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA=="],
|
|
|
|
| 1012 |
|
| 1013 |
"word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="],
|
| 1014 |
|
| 1015 |
+
"ws": ["ws@8.22.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg=="],
|
| 1016 |
+
|
| 1017 |
"yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
|
| 1018 |
|
| 1019 |
"zustand": ["zustand@5.0.11", "", { "peerDependencies": { "@types/react": ">=18.0.0", "immer": ">=9.0.6", "react": ">=18.0.0", "use-sync-external-store": ">=1.2.0" }, "optionalPeers": ["@types/react", "immer", "react", "use-sync-external-store"] }, "sha512-fdZY+dk7zn/vbWNCYmzZULHRrss0jx5pPFiOuMZ/5HJN6Yv3u+1Wswy/4MpZEkEGhtNH+pwxZB8OKgUBPzYAGg=="],
|
package.json
CHANGED
|
@@ -16,9 +16,11 @@
|
|
| 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",
|
| 23 |
"next": "15.3.6",
|
| 24 |
"react": "^19.0.0",
|
|
@@ -38,6 +40,7 @@
|
|
| 38 |
"@types/three": "^0.182.0",
|
| 39 |
"eslint": "^9",
|
| 40 |
"eslint-config-next": "15.3.1",
|
|
|
|
| 41 |
"prettier": "3.8.2",
|
| 42 |
"tailwindcss": "^4",
|
| 43 |
"typescript": "^5"
|
|
|
|
| 16 |
},
|
| 17 |
"dependencies": {
|
| 18 |
"@huggingface/hub": "^2.11.0",
|
| 19 |
+
"@huggingface/lerobot": "^0.0.13",
|
| 20 |
+
"@huggingface/usd-web-viewer": "0.2.0",
|
| 21 |
"@react-three/drei": "^10.7.7",
|
| 22 |
"@react-three/fiber": "^9.5.0",
|
| 23 |
+
"fzstd": "0.1.1",
|
| 24 |
"hyparquet": "^1.12.1",
|
| 25 |
"next": "15.3.6",
|
| 26 |
"react": "^19.0.0",
|
|
|
|
| 40 |
"@types/three": "^0.182.0",
|
| 41 |
"eslint": "^9",
|
| 42 |
"eslint-config-next": "15.3.1",
|
| 43 |
+
"happy-dom": "21.0.4",
|
| 44 |
"prettier": "3.8.2",
|
| 45 |
"tailwindcss": "^4",
|
| 46 |
"typescript": "^5"
|
src/app/[org]/[dataset]/[episode]/episode-viewer.tsx
CHANGED
|
@@ -6,6 +6,8 @@ import { postParentMessageWithParams } from "@/utils/postParentMessage";
|
|
| 6 |
import { SimpleVideosPlayer } from "@/components/simple-videos-player";
|
| 7 |
import PlaybackBar from "@/components/playback-bar";
|
| 8 |
import { TimeProvider, useTime, useTimeControls } from "@/context/time-context";
|
|
|
|
|
|
|
| 9 |
import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
|
| 10 |
import {
|
| 11 |
AnnotationsProvider,
|
|
@@ -37,6 +39,7 @@ import { getDatasetVersionAndInfo } from "@/utils/versionUtils";
|
|
| 37 |
import type { DatasetMetadata } from "@/utils/parquetUtils";
|
| 38 |
|
| 39 |
const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
|
|
|
|
| 40 |
const ActionInsightsPanel = lazy(
|
| 41 |
() => import("@/components/action-insights-panel"),
|
| 42 |
);
|
|
@@ -71,6 +74,7 @@ const TABS = [
|
|
| 71 |
"filtering",
|
| 72 |
"doctor",
|
| 73 |
"urdf",
|
|
|
|
| 74 |
] as const;
|
| 75 |
type ActiveTab = (typeof TABS)[number];
|
| 76 |
|
|
@@ -118,6 +122,11 @@ const ADVANCED_TABS: { tab: ActiveTab; label: string; title?: string }[] = [
|
|
| 118 |
title:
|
| 119 |
"Edit subtask / plan / memory / interjection / VQA atoms (lerobot v3.1 schema)",
|
| 120 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
{ tab: "statistics", label: "Statistics" },
|
| 122 |
{ tab: "filtering", label: "Filtering" },
|
| 123 |
{ tab: "frames", label: "Frames" },
|
|
@@ -132,13 +141,16 @@ const ADVANCED_TABS: { tab: ActiveTab; label: string; title?: string }[] = [
|
|
| 132 |
function AdvancedTabsMenu({
|
| 133 |
activeTab,
|
| 134 |
onSelect,
|
|
|
|
| 135 |
}: {
|
| 136 |
activeTab: ActiveTab;
|
| 137 |
onSelect: (tab: ActiveTab) => void;
|
|
|
|
| 138 |
}) {
|
| 139 |
const [open, setOpen] = useState(false);
|
| 140 |
const rootRef = useRef<HTMLDivElement>(null);
|
| 141 |
-
const
|
|
|
|
| 142 |
|
| 143 |
useEffect(() => {
|
| 144 |
if (!open) return;
|
|
@@ -193,7 +205,7 @@ function AdvancedTabsMenu({
|
|
| 193 |
role="menu"
|
| 194 |
className="absolute left-0 top-full z-50 mt-1 min-w-[12rem] panel-raised bg-[var(--surface-1)] p-1.5 shadow-xl animate-menu-pop [transform-origin:top_left]"
|
| 195 |
>
|
| 196 |
-
{
|
| 197 |
<button
|
| 198 |
key={tab}
|
| 199 |
role="menuitem"
|
|
@@ -265,6 +277,9 @@ export default function EpisodeViewer({
|
|
| 265 |
const [error, setError] = useState<string | null>(null);
|
| 266 |
const [noEpisodes, setNoEpisodes] = useState(false);
|
| 267 |
const requestIdRef = useRef(0);
|
|
|
|
|
|
|
|
|
|
| 268 |
|
| 269 |
// Opening straight onto 3D Replay: start the robot meshes downloading now,
|
| 270 |
// alongside the episode data, rather than once it has loaded and the viewer
|
|
@@ -291,6 +306,14 @@ export default function EpisodeViewer({
|
|
| 291 |
setData(null);
|
| 292 |
return;
|
| 293 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 294 |
const requestId = ++requestIdRef.current;
|
| 295 |
setError(null);
|
| 296 |
setNoEpisodes(false);
|
|
@@ -315,7 +338,7 @@ export default function EpisodeViewer({
|
|
| 315 |
setError(message || "Unknown error");
|
| 316 |
setData(null);
|
| 317 |
});
|
| 318 |
-
}, [org, dataset, episodeId]);
|
| 319 |
|
| 320 |
if (noEpisodes) {
|
| 321 |
return (
|
|
@@ -347,6 +370,9 @@ export default function EpisodeViewer({
|
|
| 347 |
<p className="text-sm font-mono whitespace-pre-wrap text-red-700/90 dark:text-red-200/90">
|
| 348 |
{error}
|
| 349 |
</p>
|
|
|
|
|
|
|
|
|
|
| 350 |
</div>
|
| 351 |
</div>
|
| 352 |
);
|
|
@@ -426,11 +452,19 @@ function EpisodeViewerInner({
|
|
| 426 |
// Safe because EpisodeViewerInner only mounts client-side (behind a loading gate).
|
| 427 |
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
| 428 |
if (typeof window !== "undefined") {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 429 |
const stored = sessionStorage.getItem("activeTab");
|
| 430 |
/// A tab saved on another dataset may not exist here (3D Replay needs a supported robot).
|
| 431 |
if (
|
| 432 |
TABS.includes(stored as ActiveTab) &&
|
| 433 |
-
(stored !== "urdf" || urdfSupported)
|
|
|
|
| 434 |
) {
|
| 435 |
return stored as ActiveTab;
|
| 436 |
}
|
|
@@ -741,7 +775,11 @@ function EpisodeViewerInner({
|
|
| 741 |
<div className="relative z-30 flex items-center border-b border-line-subtle bg-[var(--surface-0)] shrink-0">
|
| 742 |
{renderTab("episodes", "Episodes")}
|
| 743 |
{urdfSupported && renderTab("urdf", "3D Replay")}
|
| 744 |
-
<AdvancedTabsMenu
|
|
|
|
|
|
|
|
|
|
|
|
|
| 745 |
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
|
| 746 |
<ThemeToggle />
|
| 747 |
<HfAuthButton variant="tab" />
|
|
@@ -753,6 +791,7 @@ function EpisodeViewerInner({
|
|
| 753 |
{/* Sidebar — on Episodes and 3D Replay tabs */}
|
| 754 |
{(activeTab === "episodes" ||
|
| 755 |
activeTab === "annotations" ||
|
|
|
|
| 756 |
activeTab === "urdf") && (
|
| 757 |
<Sidebar
|
| 758 |
datasetInfo={datasetInfo}
|
|
@@ -795,6 +834,7 @@ function EpisodeViewerInner({
|
|
| 795 |
<SimpleVideosPlayer
|
| 796 |
videosInfo={videosInfo}
|
| 797 |
onVideosReady={() => setVideosReady(true)}
|
|
|
|
| 798 |
/>
|
| 799 |
)}
|
| 800 |
|
|
@@ -846,6 +886,7 @@ function EpisodeViewerInner({
|
|
| 846 |
<SimpleVideosPlayer
|
| 847 |
videosInfo={videosInfo}
|
| 848 |
onVideosReady={() => setVideosReady(true)}
|
|
|
|
| 849 |
/>
|
| 850 |
</div>
|
| 851 |
)}
|
|
@@ -942,6 +983,20 @@ function EpisodeViewerInner({
|
|
| 942 |
/>
|
| 943 |
</Suspense>
|
| 944 |
)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 945 |
</div>
|
| 946 |
</div>
|
| 947 |
</div>
|
|
|
|
| 6 |
import { SimpleVideosPlayer } from "@/components/simple-videos-player";
|
| 7 |
import PlaybackBar from "@/components/playback-bar";
|
| 8 |
import { TimeProvider, useTime, useTimeControls } from "@/context/time-context";
|
| 9 |
+
import { useAuth } from "@/context/auth-context";
|
| 10 |
+
import { getAuthToken } from "@/utils/auth";
|
| 11 |
import { FlaggedEpisodesProvider } from "@/context/flagged-episodes-context";
|
| 12 |
import {
|
| 13 |
AnnotationsProvider,
|
|
|
|
| 39 |
import type { DatasetMetadata } from "@/utils/parquetUtils";
|
| 40 |
|
| 41 |
const URDFViewer = lazy(() => import("@/components/urdf-viewer"));
|
| 42 |
+
const HandReplay = lazy(() => import("@/components/hand-replay"));
|
| 43 |
const ActionInsightsPanel = lazy(
|
| 44 |
() => import("@/components/action-insights-panel"),
|
| 45 |
);
|
|
|
|
| 74 |
"filtering",
|
| 75 |
"doctor",
|
| 76 |
"urdf",
|
| 77 |
+
"hands",
|
| 78 |
] as const;
|
| 79 |
type ActiveTab = (typeof TABS)[number];
|
| 80 |
|
|
|
|
| 122 |
title:
|
| 123 |
"Edit subtask / plan / memory / interjection / VQA atoms (lerobot v3.1 schema)",
|
| 124 |
},
|
| 125 |
+
{
|
| 126 |
+
tab: "hands",
|
| 127 |
+
label: "3D Hands",
|
| 128 |
+
title: "Synchronized wrists, hands and camera overlays",
|
| 129 |
+
},
|
| 130 |
{ tab: "statistics", label: "Statistics" },
|
| 131 |
{ tab: "filtering", label: "Filtering" },
|
| 132 |
{ tab: "frames", label: "Frames" },
|
|
|
|
| 141 |
function AdvancedTabsMenu({
|
| 142 |
activeTab,
|
| 143 |
onSelect,
|
| 144 |
+
handsAvailable,
|
| 145 |
}: {
|
| 146 |
activeTab: ActiveTab;
|
| 147 |
onSelect: (tab: ActiveTab) => void;
|
| 148 |
+
handsAvailable: boolean;
|
| 149 |
}) {
|
| 150 |
const [open, setOpen] = useState(false);
|
| 151 |
const rootRef = useRef<HTMLDivElement>(null);
|
| 152 |
+
const tabs = ADVANCED_TABS.filter((t) => t.tab !== "hands" || handsAvailable);
|
| 153 |
+
const active = tabs.find((t) => t.tab === activeTab);
|
| 154 |
|
| 155 |
useEffect(() => {
|
| 156 |
if (!open) return;
|
|
|
|
| 205 |
role="menu"
|
| 206 |
className="absolute left-0 top-full z-50 mt-1 min-w-[12rem] panel-raised bg-[var(--surface-1)] p-1.5 shadow-xl animate-menu-pop [transform-origin:top_left]"
|
| 207 |
>
|
| 208 |
+
{tabs.map(({ tab, label, title }) => (
|
| 209 |
<button
|
| 210 |
key={tab}
|
| 211 |
role="menuitem"
|
|
|
|
| 277 |
const [error, setError] = useState<string | null>(null);
|
| 278 |
const [noEpisodes, setNoEpisodes] = useState(false);
|
| 279 |
const requestIdRef = useRef(0);
|
| 280 |
+
const loadedRef = useRef<{ key: string; token: string | null } | null>(null);
|
| 281 |
+
const { oauth } = useAuth();
|
| 282 |
+
const accessToken = oauth?.accessToken ?? null;
|
| 283 |
|
| 284 |
// Opening straight onto 3D Replay: start the robot meshes downloading now,
|
| 285 |
// alongside the episode data, rather than once it has loaded and the viewer
|
|
|
|
| 306 |
setData(null);
|
| 307 |
return;
|
| 308 |
}
|
| 309 |
+
// Requests read the token from storage, so the first load already uses a
|
| 310 |
+
// saved sign-in, which AuthProvider restores into `accessToken` only
|
| 311 |
+
// afterwards. Reload for a new token, not for that one.
|
| 312 |
+
const key = `${org}/${dataset}/${episodeId}`;
|
| 313 |
+
const token = getAuthToken();
|
| 314 |
+
if (loadedRef.current?.key === key && loadedRef.current.token === token)
|
| 315 |
+
return;
|
| 316 |
+
loadedRef.current = { key, token };
|
| 317 |
const requestId = ++requestIdRef.current;
|
| 318 |
setError(null);
|
| 319 |
setNoEpisodes(false);
|
|
|
|
| 338 |
setError(message || "Unknown error");
|
| 339 |
setData(null);
|
| 340 |
});
|
| 341 |
+
}, [org, dataset, episodeId, accessToken]);
|
| 342 |
|
| 343 |
if (noEpisodes) {
|
| 344 |
return (
|
|
|
|
| 370 |
<p className="text-sm font-mono whitespace-pre-wrap text-red-700/90 dark:text-red-200/90">
|
| 371 |
{error}
|
| 372 |
</p>
|
| 373 |
+
<div className="mt-4">
|
| 374 |
+
<HfAuthButton variant="ghost" />
|
| 375 |
+
</div>
|
| 376 |
</div>
|
| 377 |
</div>
|
| 378 |
);
|
|
|
|
| 452 |
// Safe because EpisodeViewerInner only mounts client-side (behind a loading gate).
|
| 453 |
const [activeTab, setActiveTab] = useState<ActiveTab>(() => {
|
| 454 |
if (typeof window !== "undefined") {
|
| 455 |
+
const requested = new URLSearchParams(window.location.search).get("tab");
|
| 456 |
+
if (
|
| 457 |
+
TABS.includes(requested as ActiveTab) &&
|
| 458 |
+
(requested !== "hands" || data.handTracking) &&
|
| 459 |
+
(requested !== "urdf" || urdfSupported)
|
| 460 |
+
)
|
| 461 |
+
return requested as ActiveTab;
|
| 462 |
const stored = sessionStorage.getItem("activeTab");
|
| 463 |
/// A tab saved on another dataset may not exist here (3D Replay needs a supported robot).
|
| 464 |
if (
|
| 465 |
TABS.includes(stored as ActiveTab) &&
|
| 466 |
+
(stored !== "urdf" || urdfSupported) &&
|
| 467 |
+
(stored !== "hands" || data.handTracking)
|
| 468 |
) {
|
| 469 |
return stored as ActiveTab;
|
| 470 |
}
|
|
|
|
| 775 |
<div className="relative z-30 flex items-center border-b border-line-subtle bg-[var(--surface-0)] shrink-0">
|
| 776 |
{renderTab("episodes", "Episodes")}
|
| 777 |
{urdfSupported && renderTab("urdf", "3D Replay")}
|
| 778 |
+
<AdvancedTabsMenu
|
| 779 |
+
activeTab={activeTab}
|
| 780 |
+
onSelect={handleTabChange}
|
| 781 |
+
handsAvailable={!!data.handTracking}
|
| 782 |
+
/>
|
| 783 |
<div className="ml-auto flex shrink-0 items-center gap-1 pl-2">
|
| 784 |
<ThemeToggle />
|
| 785 |
<HfAuthButton variant="tab" />
|
|
|
|
| 791 |
{/* Sidebar — on Episodes and 3D Replay tabs */}
|
| 792 |
{(activeTab === "episodes" ||
|
| 793 |
activeTab === "annotations" ||
|
| 794 |
+
activeTab === "hands" ||
|
| 795 |
activeTab === "urdf") && (
|
| 796 |
<Sidebar
|
| 797 |
datasetInfo={datasetInfo}
|
|
|
|
| 834 |
<SimpleVideosPlayer
|
| 835 |
videosInfo={videosInfo}
|
| 836 |
onVideosReady={() => setVideosReady(true)}
|
| 837 |
+
handTracking={data.handTracking}
|
| 838 |
/>
|
| 839 |
)}
|
| 840 |
|
|
|
|
| 886 |
<SimpleVideosPlayer
|
| 887 |
videosInfo={videosInfo}
|
| 888 |
onVideosReady={() => setVideosReady(true)}
|
| 889 |
+
handTracking={data.handTracking}
|
| 890 |
/>
|
| 891 |
</div>
|
| 892 |
)}
|
|
|
|
| 983 |
/>
|
| 984 |
</Suspense>
|
| 985 |
)}
|
| 986 |
+
{activeTab === "hands" && data.handTracking && (
|
| 987 |
+
<>
|
| 988 |
+
<EpisodeHeader
|
| 989 |
+
repoId={datasetInfo.repoId}
|
| 990 |
+
episodeId={episodeId}
|
| 991 |
+
/>
|
| 992 |
+
<Suspense fallback={<Loading />}>
|
| 993 |
+
<HandReplay
|
| 994 |
+
tracking={data.handTracking}
|
| 995 |
+
videosInfo={videosInfo}
|
| 996 |
+
/>
|
| 997 |
+
</Suspense>
|
| 998 |
+
</>
|
| 999 |
+
)}
|
| 1000 |
</div>
|
| 1001 |
</div>
|
| 1002 |
</div>
|
src/app/[org]/[dataset]/[episode]/fetch-data.ts
CHANGED
|
@@ -2,8 +2,10 @@ import {
|
|
| 2 |
DatasetMetadata,
|
| 3 |
fetchParquetFile,
|
| 4 |
formatStringWithVars,
|
|
|
|
| 5 |
readParquetAsObjects,
|
| 6 |
} from "@/utils/parquetUtils";
|
|
|
|
| 7 |
import { pick } from "@/utils/pick";
|
| 8 |
import {
|
| 9 |
getDatasetVersionAndInfo,
|
|
@@ -31,6 +33,13 @@ import {
|
|
| 31 |
} from "@huggingface/lerobot";
|
| 32 |
import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
|
| 33 |
import { authHeaders, getAuthToken } from "@/utils/auth";
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
|
| 35 |
const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
|
| 36 |
|
|
@@ -279,6 +288,8 @@ export type EpisodeData = {
|
|
| 279 |
* events compatible with the writer in lerobot#3471.
|
| 280 |
*/
|
| 281 |
frameTimestamps?: number[];
|
|
|
|
|
|
|
| 282 |
};
|
| 283 |
|
| 284 |
type EpisodeMetadataV3 = {
|
|
@@ -745,7 +756,11 @@ async function getEpisodeDataV2(
|
|
| 745 |
const videosInfo =
|
| 746 |
info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
|
| 747 |
|
| 748 |
-
const { frames, textRows } = await loadEpisodeFrames(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 749 |
const frameTimestamps = sortedTimestamps(frames);
|
| 750 |
const languageAtoms = extractLanguageAtoms(textRows);
|
| 751 |
|
|
@@ -842,6 +857,7 @@ async function getEpisodeDataV2(
|
|
| 842 |
task,
|
| 843 |
languageAtoms,
|
| 844 |
frameTimestamps,
|
|
|
|
| 845 |
};
|
| 846 |
}
|
| 847 |
|
|
@@ -877,6 +893,7 @@ async function getEpisodeDataV3(
|
|
| 877 |
task,
|
| 878 |
languageAtoms,
|
| 879 |
frameTimestamps,
|
|
|
|
| 880 |
} = await loadEpisodeDataV3(
|
| 881 |
repoId,
|
| 882 |
version,
|
|
@@ -901,6 +918,7 @@ async function getEpisodeDataV3(
|
|
| 901 |
task,
|
| 902 |
languageAtoms,
|
| 903 |
frameTimestamps,
|
|
|
|
| 904 |
};
|
| 905 |
}
|
| 906 |
|
|
@@ -918,9 +936,14 @@ async function loadEpisodeDataV3(
|
|
| 918 |
task?: string;
|
| 919 |
languageAtoms?: import("@/types/language.types").LanguageAtom[];
|
| 920 |
frameTimestamps?: number[];
|
|
|
|
| 921 |
}> {
|
| 922 |
try {
|
| 923 |
-
const { frames, textRows } = await loadEpisodeFrames(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 924 |
const frameTimestamps = sortedTimestamps(frames);
|
| 925 |
|
| 926 |
if (!frames) {
|
|
@@ -931,6 +954,7 @@ async function loadEpisodeDataV3(
|
|
| 931 |
task: undefined,
|
| 932 |
languageAtoms: extractLanguageAtoms(textRows),
|
| 933 |
frameTimestamps,
|
|
|
|
| 934 |
};
|
| 935 |
}
|
| 936 |
|
|
@@ -1041,6 +1065,7 @@ async function loadEpisodeDataV3(
|
|
| 1041 |
task,
|
| 1042 |
languageAtoms: extractLanguageAtoms(textRows),
|
| 1043 |
frameTimestamps,
|
|
|
|
| 1044 |
};
|
| 1045 |
} catch {
|
| 1046 |
return {
|
|
@@ -1149,26 +1174,70 @@ const TEXT_COLUMNS = [
|
|
| 1149 |
"language_events",
|
| 1150 |
];
|
| 1151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1152 |
/** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
|
| 1153 |
async function loadEpisodeFrames(
|
| 1154 |
repoId: string,
|
| 1155 |
episode: LeRobotEpisode,
|
|
|
|
| 1156 |
): Promise<{
|
| 1157 |
frames: LeRobotFrames | undefined;
|
| 1158 |
textRows: Record<string, unknown>[];
|
|
|
|
| 1159 |
}> {
|
| 1160 |
const data = episode.data;
|
| 1161 |
if (!data) return { frames: undefined, textRows: [] };
|
| 1162 |
-
const [frames, textRows] = await Promise.all([
|
| 1163 |
leRobotDataset(repoId).frames(episode),
|
| 1164 |
-
fetchParquetFile(data.url).then((file) =>
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
| 1168 |
-
|
| 1169 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1170 |
]);
|
| 1171 |
-
return {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1172 |
}
|
| 1173 |
|
| 1174 |
/** Source-frame timestamps, sorted, for the annotations editor to snap to. */
|
|
@@ -1192,6 +1261,7 @@ function chartDataFromFrames(
|
|
| 1192 |
const series: [string, number[]][] = [];
|
| 1193 |
for (const [key, components] of Object.entries(frames.series)) {
|
| 1194 |
if (!info.features[key] || excludedColumns.includes(key)) continue;
|
|
|
|
| 1195 |
const names =
|
| 1196 |
frames.names[key]?.length === components.length
|
| 1197 |
? frames.names[key]
|
|
|
|
| 2 |
DatasetMetadata,
|
| 3 |
fetchParquetFile,
|
| 4 |
formatStringWithVars,
|
| 5 |
+
nestedColumnValues,
|
| 6 |
readParquetAsObjects,
|
| 7 |
} from "@/utils/parquetUtils";
|
| 8 |
+
import { parquetMetadataAsync } from "hyparquet";
|
| 9 |
import { pick } from "@/utils/pick";
|
| 10 |
import {
|
| 11 |
getDatasetVersionAndInfo,
|
|
|
|
| 33 |
} from "@huggingface/lerobot";
|
| 34 |
import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
|
| 35 |
import { authHeaders, getAuthToken } from "@/utils/auth";
|
| 36 |
+
import {
|
| 37 |
+
hasHandTracking,
|
| 38 |
+
handProjectionMetadata,
|
| 39 |
+
isHandGeometryColumn,
|
| 40 |
+
readHandTracking,
|
| 41 |
+
type HandTracking,
|
| 42 |
+
} from "@/lib/hand-tracking";
|
| 43 |
|
| 44 |
const SERIES_NAME_DELIMITER = CHART_CONFIG.SERIES_NAME_DELIMITER;
|
| 45 |
|
|
|
|
| 288 |
* events compatible with the writer in lerobot#3471.
|
| 289 |
*/
|
| 290 |
frameTimestamps?: number[];
|
| 291 |
+
/** Full-resolution native hand geometry, independent of sampled chart rows. */
|
| 292 |
+
handTracking?: HandTracking;
|
| 293 |
};
|
| 294 |
|
| 295 |
type EpisodeMetadataV3 = {
|
|
|
|
| 756 |
const videosInfo =
|
| 757 |
info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
|
| 758 |
|
| 759 |
+
const { frames, textRows, handTracking } = await loadEpisodeFrames(
|
| 760 |
+
repoId,
|
| 761 |
+
leRobotEpisode,
|
| 762 |
+
info,
|
| 763 |
+
);
|
| 764 |
const frameTimestamps = sortedTimestamps(frames);
|
| 765 |
const languageAtoms = extractLanguageAtoms(textRows);
|
| 766 |
|
|
|
|
| 857 |
task,
|
| 858 |
languageAtoms,
|
| 859 |
frameTimestamps,
|
| 860 |
+
handTracking,
|
| 861 |
};
|
| 862 |
}
|
| 863 |
|
|
|
|
| 893 |
task,
|
| 894 |
languageAtoms,
|
| 895 |
frameTimestamps,
|
| 896 |
+
handTracking,
|
| 897 |
} = await loadEpisodeDataV3(
|
| 898 |
repoId,
|
| 899 |
version,
|
|
|
|
| 918 |
task,
|
| 919 |
languageAtoms,
|
| 920 |
frameTimestamps,
|
| 921 |
+
handTracking,
|
| 922 |
};
|
| 923 |
}
|
| 924 |
|
|
|
|
| 936 |
task?: string;
|
| 937 |
languageAtoms?: import("@/types/language.types").LanguageAtom[];
|
| 938 |
frameTimestamps?: number[];
|
| 939 |
+
handTracking?: HandTracking;
|
| 940 |
}> {
|
| 941 |
try {
|
| 942 |
+
const { frames, textRows, handTracking } = await loadEpisodeFrames(
|
| 943 |
+
repoId,
|
| 944 |
+
episode,
|
| 945 |
+
info,
|
| 946 |
+
);
|
| 947 |
const frameTimestamps = sortedTimestamps(frames);
|
| 948 |
|
| 949 |
if (!frames) {
|
|
|
|
| 954 |
task: undefined,
|
| 955 |
languageAtoms: extractLanguageAtoms(textRows),
|
| 956 |
frameTimestamps,
|
| 957 |
+
handTracking,
|
| 958 |
};
|
| 959 |
}
|
| 960 |
|
|
|
|
| 1065 |
task,
|
| 1066 |
languageAtoms: extractLanguageAtoms(textRows),
|
| 1067 |
frameTimestamps,
|
| 1068 |
+
handTracking,
|
| 1069 |
};
|
| 1070 |
} catch {
|
| 1071 |
return {
|
|
|
|
| 1174 |
"language_events",
|
| 1175 |
];
|
| 1176 |
|
| 1177 |
+
/**
|
| 1178 |
+
* Past this many values in its row groups, a nested text column is left out: hyparquet decodes it
|
| 1179 |
+
* for the whole row group, and a file written as one row group can hold millions of atoms. 22M
|
| 1180 |
+
* took 2-3 GB of heap; 44M (MinhDuk/final_dataset_annotated) and 66M crashed the tab.
|
| 1181 |
+
*/
|
| 1182 |
+
const MAX_NESTED_TEXT_VALUES = 25_000_000;
|
| 1183 |
+
|
| 1184 |
/** The episode's numeric series from `@huggingface/lerobot`, plus its text columns read alongside. */
|
| 1185 |
async function loadEpisodeFrames(
|
| 1186 |
repoId: string,
|
| 1187 |
episode: LeRobotEpisode,
|
| 1188 |
+
info: DatasetMetadata,
|
| 1189 |
): Promise<{
|
| 1190 |
frames: LeRobotFrames | undefined;
|
| 1191 |
textRows: Record<string, unknown>[];
|
| 1192 |
+
handTracking?: HandTracking;
|
| 1193 |
}> {
|
| 1194 |
const data = episode.data;
|
| 1195 |
if (!data) return { frames: undefined, textRows: [] };
|
| 1196 |
+
const [frames, textRows, report] = await Promise.all([
|
| 1197 |
leRobotDataset(repoId).frames(episode),
|
| 1198 |
+
fetchParquetFile(data.url).then(async (file) => {
|
| 1199 |
+
const values = nestedColumnValues(
|
| 1200 |
+
await parquetMetadataAsync(file),
|
| 1201 |
+
data.fromRow,
|
| 1202 |
+
data.toRow,
|
| 1203 |
+
);
|
| 1204 |
+
const skipped = TEXT_COLUMNS.filter(
|
| 1205 |
+
(column) => (values.get(column) ?? 0) > MAX_NESTED_TEXT_VALUES,
|
| 1206 |
+
);
|
| 1207 |
+
if (skipped.length > 0)
|
| 1208 |
+
console.warn(
|
| 1209 |
+
`[language] ${skipped.join(", ")} left out: its row group is too large to decode`,
|
| 1210 |
+
);
|
| 1211 |
+
return readParquetAsObjects(
|
| 1212 |
+
file,
|
| 1213 |
+
TEXT_COLUMNS.filter((column) => !skipped.includes(column)),
|
| 1214 |
+
{ rowStart: data.fromRow, rowEnd: data.toRow },
|
| 1215 |
+
);
|
| 1216 |
+
}),
|
| 1217 |
+
hasHandTracking(info)
|
| 1218 |
+
? fetch(
|
| 1219 |
+
buildVersionedUrl(
|
| 1220 |
+
repoId,
|
| 1221 |
+
info.codebase_version,
|
| 1222 |
+
"annotation_studio/run.json",
|
| 1223 |
+
),
|
| 1224 |
+
{ headers: authHeaders(), signal: AbortSignal.timeout(10_000) },
|
| 1225 |
+
)
|
| 1226 |
+
.then((response) =>
|
| 1227 |
+
response.ok ? (response.json() as Promise<unknown>) : null,
|
| 1228 |
+
)
|
| 1229 |
+
.catch(() => null)
|
| 1230 |
+
: Promise.resolve(null),
|
| 1231 |
]);
|
| 1232 |
+
return {
|
| 1233 |
+
frames,
|
| 1234 |
+
textRows,
|
| 1235 |
+
handTracking: readHandTracking(
|
| 1236 |
+
info,
|
| 1237 |
+
frames,
|
| 1238 |
+
handProjectionMetadata(info, episode.index, report),
|
| 1239 |
+
),
|
| 1240 |
+
};
|
| 1241 |
}
|
| 1242 |
|
| 1243 |
/** Source-frame timestamps, sorted, for the annotations editor to snap to. */
|
|
|
|
| 1261 |
const series: [string, number[]][] = [];
|
| 1262 |
for (const [key, components] of Object.entries(frames.series)) {
|
| 1263 |
if (!info.features[key] || excludedColumns.includes(key)) continue;
|
| 1264 |
+
if (hasHandTracking(info) && isHandGeometryColumn(key)) continue;
|
| 1265 |
const names =
|
| 1266 |
frames.names[key]?.length === components.length
|
| 1267 |
? frames.names[key]
|
src/components/__tests__/simple-videos-player.test.tsx
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import {
|
| 2 |
+
afterAll,
|
| 3 |
+
afterEach,
|
| 4 |
+
beforeAll,
|
| 5 |
+
describe,
|
| 6 |
+
expect,
|
| 7 |
+
test,
|
| 8 |
+
} from "bun:test";
|
| 9 |
+
import { Window } from "happy-dom";
|
| 10 |
+
import { act, useEffect, useState } from "react";
|
| 11 |
+
import { createRoot, type Root } from "react-dom/client";
|
| 12 |
+
import { AnnotationsProvider } from "@/context/annotations-context";
|
| 13 |
+
import { TimeProvider, useTime } from "@/context/time-context";
|
| 14 |
+
import type { VideoInfo } from "@/types";
|
| 15 |
+
import type { HandTracking } from "@/lib/hand-tracking";
|
| 16 |
+
import { SimpleVideosPlayer } from "../simple-videos-player";
|
| 17 |
+
|
| 18 |
+
const dom = new Window({ url: "http://localhost" });
|
| 19 |
+
const originals = new Map<string, PropertyDescriptor | undefined>();
|
| 20 |
+
let root: Root;
|
| 21 |
+
let container: HTMLDivElement;
|
| 22 |
+
let time: ReturnType<typeof useTime>;
|
| 23 |
+
|
| 24 |
+
beforeAll(() => {
|
| 25 |
+
const globals = {
|
| 26 |
+
window: dom,
|
| 27 |
+
document: dom.document,
|
| 28 |
+
HTMLVideoElement: dom.HTMLVideoElement,
|
| 29 |
+
HTMLMediaElement: dom.HTMLMediaElement,
|
| 30 |
+
Event: dom.Event,
|
| 31 |
+
ResizeObserver: dom.ResizeObserver,
|
| 32 |
+
requestAnimationFrame: dom.requestAnimationFrame.bind(dom),
|
| 33 |
+
cancelAnimationFrame: dom.cancelAnimationFrame.bind(dom),
|
| 34 |
+
IS_REACT_ACT_ENVIRONMENT: true,
|
| 35 |
+
};
|
| 36 |
+
for (const [key, value] of Object.entries(globals)) {
|
| 37 |
+
originals.set(key, Object.getOwnPropertyDescriptor(globalThis, key));
|
| 38 |
+
Object.defineProperty(globalThis, key, {
|
| 39 |
+
configurable: true,
|
| 40 |
+
writable: true,
|
| 41 |
+
value,
|
| 42 |
+
});
|
| 43 |
+
}
|
| 44 |
+
});
|
| 45 |
+
|
| 46 |
+
afterEach(async () => {
|
| 47 |
+
if (root) await act(async () => root.unmount());
|
| 48 |
+
container?.remove();
|
| 49 |
+
});
|
| 50 |
+
|
| 51 |
+
afterAll(async () => {
|
| 52 |
+
await dom.happyDOM.close();
|
| 53 |
+
for (const [key, descriptor] of originals) {
|
| 54 |
+
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
| 55 |
+
else Reflect.deleteProperty(globalThis, key);
|
| 56 |
+
}
|
| 57 |
+
});
|
| 58 |
+
|
| 59 |
+
const videos: VideoInfo[] = [
|
| 60 |
+
{
|
| 61 |
+
filename: "ego",
|
| 62 |
+
url: "http://localhost/ego.mp4",
|
| 63 |
+
isSegmented: true,
|
| 64 |
+
segmentStart: 12.5,
|
| 65 |
+
segmentEnd: 22.5,
|
| 66 |
+
},
|
| 67 |
+
{
|
| 68 |
+
filename: "other",
|
| 69 |
+
url: "http://localhost/other.mp4",
|
| 70 |
+
isSegmented: true,
|
| 71 |
+
segmentStart: 30,
|
| 72 |
+
segmentEnd: 40,
|
| 73 |
+
},
|
| 74 |
+
];
|
| 75 |
+
|
| 76 |
+
async function mount(initialTime = 0, handTracking?: HandTracking) {
|
| 77 |
+
let showPlayer: () => void;
|
| 78 |
+
function Controls() {
|
| 79 |
+
time = useTime();
|
| 80 |
+
const { seek } = time;
|
| 81 |
+
useEffect(() => seek(initialTime), [seek]);
|
| 82 |
+
return null;
|
| 83 |
+
}
|
| 84 |
+
function DeferredPlayer() {
|
| 85 |
+
const [visible, setVisible] = useState(false);
|
| 86 |
+
showPlayer = () => setVisible(true);
|
| 87 |
+
return visible ? (
|
| 88 |
+
<SimpleVideosPlayer videosInfo={videos} handTracking={handTracking} />
|
| 89 |
+
) : null;
|
| 90 |
+
}
|
| 91 |
+
container = document.createElement("div");
|
| 92 |
+
document.body.append(container);
|
| 93 |
+
root = createRoot(container);
|
| 94 |
+
await act(async () => {
|
| 95 |
+
root.render(
|
| 96 |
+
<TimeProvider duration={10}>
|
| 97 |
+
<AnnotationsProvider>
|
| 98 |
+
<Controls />
|
| 99 |
+
<DeferredPlayer />
|
| 100 |
+
</AnnotationsProvider>
|
| 101 |
+
</TimeProvider>,
|
| 102 |
+
);
|
| 103 |
+
});
|
| 104 |
+
// The lazy hands panel can mount after the page has already applied ?t=.
|
| 105 |
+
await act(async () => showPlayer());
|
| 106 |
+
return Array.from(container.querySelectorAll("video"));
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
async function ready(elements: HTMLVideoElement[]) {
|
| 110 |
+
await act(async () => {
|
| 111 |
+
for (const video of elements) video.dispatchEvent(new Event("loadeddata"));
|
| 112 |
+
});
|
| 113 |
+
await act(async () => time.setIsPlaying(false));
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
describe("hand replay video synchronization", () => {
|
| 117 |
+
test("restores the URL timestamp on every camera after media loads", async () => {
|
| 118 |
+
const elements = await mount(3.25);
|
| 119 |
+
await ready(elements);
|
| 120 |
+
expect(time.currentTime).toBe(3.25);
|
| 121 |
+
expect(elements.map((video) => video.currentTime)).toEqual([15.75, 33.25]);
|
| 122 |
+
});
|
| 123 |
+
|
| 124 |
+
test("uses a seek made during loading instead of the mount timestamp", async () => {
|
| 125 |
+
const elements = await mount(3);
|
| 126 |
+
await act(async () => time.seek(5));
|
| 127 |
+
await ready(elements);
|
| 128 |
+
expect(time.currentTime).toBe(5);
|
| 129 |
+
expect(elements.map((video) => video.currentTime)).toEqual([17.5, 35]);
|
| 130 |
+
});
|
| 131 |
+
|
| 132 |
+
test("paused single-frame seeks update both source videos", async () => {
|
| 133 |
+
const elements = await mount(1);
|
| 134 |
+
await ready(elements);
|
| 135 |
+
await act(async () => time.seek(2));
|
| 136 |
+
await act(async () => time.seek(2 + 1 / 30));
|
| 137 |
+
expect(elements[0].currentTime).toBeCloseTo(14.5 + 1 / 30, 8);
|
| 138 |
+
expect(elements[1].currentTime).toBeCloseTo(32 + 1 / 30, 8);
|
| 139 |
+
expect(time.isPlaying).toBe(false);
|
| 140 |
+
});
|
| 141 |
+
|
| 142 |
+
test("labels estimated calibration beside the overlay controls", async () => {
|
| 143 |
+
await mount(0, {
|
| 144 |
+
frames: [],
|
| 145 |
+
fps: 30,
|
| 146 |
+
cameraKey: "ego",
|
| 147 |
+
coordinateFrame: "segment",
|
| 148 |
+
overlayIssue: null,
|
| 149 |
+
calibration: {
|
| 150 |
+
model: "pinhole",
|
| 151 |
+
width: 640,
|
| 152 |
+
height: 480,
|
| 153 |
+
fx: 600,
|
| 154 |
+
fy: 600,
|
| 155 |
+
cx: 320,
|
| 156 |
+
cy: 240,
|
| 157 |
+
distortion: [],
|
| 158 |
+
estimated: true,
|
| 159 |
+
},
|
| 160 |
+
});
|
| 161 |
+
expect(container.textContent).toContain("Estimated camera calibration");
|
| 162 |
+
});
|
| 163 |
+
});
|
src/components/hand-overlay-canvas.tsx
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import { useEffect, useRef } from "react";
|
| 4 |
+
import {
|
| 5 |
+
HAND_COLORS,
|
| 6 |
+
HAND_EDGES,
|
| 7 |
+
HAND_INDICES,
|
| 8 |
+
handFrameIndex,
|
| 9 |
+
projectHandPoint,
|
| 10 |
+
type HandTracking,
|
| 11 |
+
} from "@/lib/hand-tracking";
|
| 12 |
+
|
| 13 |
+
/** Follow this camera's displayed frames, including paused seeks and shared-file offsets. */
|
| 14 |
+
export function HandOverlayCanvas({
|
| 15 |
+
video,
|
| 16 |
+
tracking,
|
| 17 |
+
cameraKey,
|
| 18 |
+
segmentStart = 0,
|
| 19 |
+
enabled,
|
| 20 |
+
}: {
|
| 21 |
+
video: HTMLVideoElement | null;
|
| 22 |
+
tracking: HandTracking;
|
| 23 |
+
cameraKey: string;
|
| 24 |
+
segmentStart?: number;
|
| 25 |
+
enabled: boolean;
|
| 26 |
+
}) {
|
| 27 |
+
const canvasRef = useRef<HTMLCanvasElement>(null);
|
| 28 |
+
useEffect(() => {
|
| 29 |
+
const canvas = canvasRef.current;
|
| 30 |
+
const calibration = tracking.calibration;
|
| 31 |
+
if (
|
| 32 |
+
!canvas ||
|
| 33 |
+
!video ||
|
| 34 |
+
!calibration ||
|
| 35 |
+
cameraKey !== tracking.cameraKey ||
|
| 36 |
+
!enabled
|
| 37 |
+
)
|
| 38 |
+
return;
|
| 39 |
+
let callback = 0;
|
| 40 |
+
let mediaTime = video.currentTime;
|
| 41 |
+
const draw = (time = mediaTime) => {
|
| 42 |
+
mediaTime = time;
|
| 43 |
+
const width = video.clientWidth,
|
| 44 |
+
height = video.clientHeight;
|
| 45 |
+
if (!width || !height) return;
|
| 46 |
+
const dpr = Math.min(window.devicePixelRatio || 1, 2);
|
| 47 |
+
if (
|
| 48 |
+
canvas.width !== Math.round(width * dpr) ||
|
| 49 |
+
canvas.height !== Math.round(height * dpr)
|
| 50 |
+
) {
|
| 51 |
+
canvas.width = Math.round(width * dpr);
|
| 52 |
+
canvas.height = Math.round(height * dpr);
|
| 53 |
+
}
|
| 54 |
+
const ctx = canvas.getContext("2d");
|
| 55 |
+
if (!ctx) return;
|
| 56 |
+
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
|
| 57 |
+
ctx.clearRect(0, 0, width, height);
|
| 58 |
+
// A changed crop/resolution needs a corresponding calibration, not an implicit guess.
|
| 59 |
+
if (
|
| 60 |
+
video.videoWidth !== calibration.width ||
|
| 61 |
+
video.videoHeight !== calibration.height
|
| 62 |
+
)
|
| 63 |
+
return;
|
| 64 |
+
const scale = Math.min(
|
| 65 |
+
width / calibration.width,
|
| 66 |
+
height / calibration.height,
|
| 67 |
+
);
|
| 68 |
+
const left = (width - calibration.width * scale) / 2;
|
| 69 |
+
const top = (height - calibration.height * scale) / 2;
|
| 70 |
+
const frame =
|
| 71 |
+
tracking.frames[handFrameIndex(tracking.frames, time - segmentStart)];
|
| 72 |
+
if (!frame) return;
|
| 73 |
+
ctx.save();
|
| 74 |
+
ctx.beginPath();
|
| 75 |
+
ctx.rect(
|
| 76 |
+
left,
|
| 77 |
+
top,
|
| 78 |
+
calibration.width * scale,
|
| 79 |
+
calibration.height * scale,
|
| 80 |
+
);
|
| 81 |
+
ctx.clip();
|
| 82 |
+
HAND_INDICES.forEach((hand) => {
|
| 83 |
+
const joints = frame.camera[hand];
|
| 84 |
+
if (!joints) return;
|
| 85 |
+
const points = joints.map((p): [number, number] | null => {
|
| 86 |
+
const pixel = projectHandPoint(p, calibration);
|
| 87 |
+
return pixel
|
| 88 |
+
? [left + pixel[0] * scale, top + pixel[1] * scale]
|
| 89 |
+
: null;
|
| 90 |
+
});
|
| 91 |
+
const path = () => {
|
| 92 |
+
ctx.beginPath();
|
| 93 |
+
for (const [a, b] of HAND_EDGES) {
|
| 94 |
+
const p = points[a],
|
| 95 |
+
q = points[b];
|
| 96 |
+
if (p && q) {
|
| 97 |
+
ctx.moveTo(p[0], p[1]);
|
| 98 |
+
ctx.lineTo(q[0], q[1]);
|
| 99 |
+
}
|
| 100 |
+
}
|
| 101 |
+
ctx.stroke();
|
| 102 |
+
};
|
| 103 |
+
ctx.strokeStyle = "#111827";
|
| 104 |
+
ctx.lineWidth = 5;
|
| 105 |
+
path();
|
| 106 |
+
ctx.strokeStyle = HAND_COLORS[hand];
|
| 107 |
+
ctx.lineWidth = 2.5;
|
| 108 |
+
path();
|
| 109 |
+
points.forEach((p, joint) => {
|
| 110 |
+
if (!p) return;
|
| 111 |
+
ctx.beginPath();
|
| 112 |
+
ctx.arc(p[0], p[1], joint === 0 ? 5 : 2.5, 0, Math.PI * 2);
|
| 113 |
+
ctx.fillStyle = HAND_COLORS[hand];
|
| 114 |
+
ctx.fill();
|
| 115 |
+
});
|
| 116 |
+
const wrist = points[0];
|
| 117 |
+
if (wrist) {
|
| 118 |
+
ctx.font = "bold 13px sans-serif";
|
| 119 |
+
ctx.lineWidth = 3;
|
| 120 |
+
ctx.strokeStyle = "#111827";
|
| 121 |
+
ctx.strokeText(hand === 0 ? "L" : "R", wrist[0] + 8, wrist[1] - 8);
|
| 122 |
+
ctx.fillText(hand === 0 ? "L" : "R", wrist[0] + 8, wrist[1] - 8);
|
| 123 |
+
}
|
| 124 |
+
});
|
| 125 |
+
ctx.restore();
|
| 126 |
+
};
|
| 127 |
+
const update = () => draw(video.currentTime);
|
| 128 |
+
const observer = new ResizeObserver(() => draw());
|
| 129 |
+
observer.observe(video);
|
| 130 |
+
const events = ["loadeddata", "seeked", "resize"];
|
| 131 |
+
events.forEach((event) => video.addEventListener(event, update));
|
| 132 |
+
if ("requestVideoFrameCallback" in HTMLVideoElement.prototype) {
|
| 133 |
+
const frame = (_now: number, metadata: VideoFrameCallbackMetadata) => {
|
| 134 |
+
draw(metadata.mediaTime);
|
| 135 |
+
callback = video.requestVideoFrameCallback(frame);
|
| 136 |
+
};
|
| 137 |
+
callback = video.requestVideoFrameCallback(frame);
|
| 138 |
+
} else video.addEventListener("timeupdate", update);
|
| 139 |
+
update();
|
| 140 |
+
return () => {
|
| 141 |
+
observer.disconnect();
|
| 142 |
+
events.forEach((event) => video.removeEventListener(event, update));
|
| 143 |
+
video.removeEventListener("timeupdate", update);
|
| 144 |
+
if (callback) video.cancelVideoFrameCallback(callback);
|
| 145 |
+
const ctx = canvas.getContext("2d");
|
| 146 |
+
ctx?.clearRect(0, 0, canvas.width, canvas.height);
|
| 147 |
+
};
|
| 148 |
+
}, [video, tracking, cameraKey, segmentStart, enabled]);
|
| 149 |
+
return (
|
| 150 |
+
<canvas
|
| 151 |
+
ref={canvasRef}
|
| 152 |
+
aria-hidden="true"
|
| 153 |
+
className="absolute inset-0 h-full w-full pointer-events-none"
|
| 154 |
+
/>
|
| 155 |
+
);
|
| 156 |
+
}
|
src/components/hand-replay.tsx
ADDED
|
@@ -0,0 +1,376 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"use client";
|
| 2 |
+
|
| 3 |
+
import {
|
| 4 |
+
useEffect,
|
| 5 |
+
useMemo,
|
| 6 |
+
useRef,
|
| 7 |
+
useState,
|
| 8 |
+
type ComponentRef,
|
| 9 |
+
type RefObject,
|
| 10 |
+
} from "react";
|
| 11 |
+
import { Canvas, useThree } from "@react-three/fiber";
|
| 12 |
+
import { Bounds, Grid, Html, Line, OrbitControls } from "@react-three/drei";
|
| 13 |
+
import { Vector3 } from "three";
|
| 14 |
+
import { useTime } from "@/context/time-context";
|
| 15 |
+
import { useTheme } from "@/utils/theme";
|
| 16 |
+
import {
|
| 17 |
+
HAND_COLORS,
|
| 18 |
+
HAND_EDGES,
|
| 19 |
+
HAND_INDICES,
|
| 20 |
+
cameraPathRuns,
|
| 21 |
+
fluToScene,
|
| 22 |
+
handFrameIndex,
|
| 23 |
+
wristTrail,
|
| 24 |
+
type HandTracking,
|
| 25 |
+
type HandFrame,
|
| 26 |
+
type CameraPose,
|
| 27 |
+
type Point3,
|
| 28 |
+
} from "@/lib/hand-tracking";
|
| 29 |
+
import { SimpleVideosPlayer } from "./simple-videos-player";
|
| 30 |
+
import PlaybackBar from "./playback-bar";
|
| 31 |
+
import type { VideoInfo } from "@/types";
|
| 32 |
+
|
| 33 |
+
/** A fixed episode-wide fit keeps the orbit camera still while the hands move. */
|
| 34 |
+
function RecordingBounds({ tracking }: { tracking: HandTracking }) {
|
| 35 |
+
const bounds = useMemo(() => {
|
| 36 |
+
const lo: Point3 = [Infinity, Infinity, Infinity],
|
| 37 |
+
hi: Point3 = [-Infinity, -Infinity, -Infinity];
|
| 38 |
+
for (const frame of tracking.frames) {
|
| 39 |
+
const positions = frame[tracking.coordinateFrame].flatMap(
|
| 40 |
+
(hand) => hand ?? [],
|
| 41 |
+
);
|
| 42 |
+
if (frame.cameraPose) positions.push(frame.cameraPose.position);
|
| 43 |
+
for (const p of positions) {
|
| 44 |
+
const point = fluToScene(p);
|
| 45 |
+
for (let a = 0; a < 3; a++) {
|
| 46 |
+
lo[a] = Math.min(lo[a], point[a]);
|
| 47 |
+
hi[a] = Math.max(hi[a], point[a]);
|
| 48 |
+
}
|
| 49 |
+
}
|
| 50 |
+
}
|
| 51 |
+
if (!Number.isFinite(lo[0]))
|
| 52 |
+
return { center: [0, 0, 0] as Point3, size: [1, 1, 1] as Point3 };
|
| 53 |
+
return {
|
| 54 |
+
center: lo.map((v, a) => (v + hi[a]) / 2) as Point3,
|
| 55 |
+
size: lo.map((v, a) => Math.max(hi[a] - v, 0.4)) as Point3,
|
| 56 |
+
};
|
| 57 |
+
}, [tracking]);
|
| 58 |
+
return (
|
| 59 |
+
<mesh position={bounds.center} visible={false}>
|
| 60 |
+
<boxGeometry args={bounds.size} />
|
| 61 |
+
<meshBasicMaterial />
|
| 62 |
+
</mesh>
|
| 63 |
+
);
|
| 64 |
+
}
|
| 65 |
+
|
| 66 |
+
function Hands({
|
| 67 |
+
tracking,
|
| 68 |
+
index,
|
| 69 |
+
trails,
|
| 70 |
+
}: {
|
| 71 |
+
tracking: HandTracking;
|
| 72 |
+
index: number;
|
| 73 |
+
trails: boolean;
|
| 74 |
+
}) {
|
| 75 |
+
const frame = tracking.frames[index];
|
| 76 |
+
return (
|
| 77 |
+
<>
|
| 78 |
+
{HAND_INDICES.map((hand) => {
|
| 79 |
+
const joints = frame?.[tracking.coordinateFrame][hand];
|
| 80 |
+
if (!joints) return null;
|
| 81 |
+
const points = joints.map(fluToScene),
|
| 82 |
+
color = HAND_COLORS[hand];
|
| 83 |
+
const trail = trails
|
| 84 |
+
? wristTrail(
|
| 85 |
+
tracking.frames,
|
| 86 |
+
index,
|
| 87 |
+
hand,
|
| 88 |
+
2,
|
| 89 |
+
tracking.coordinateFrame,
|
| 90 |
+
)
|
| 91 |
+
: [];
|
| 92 |
+
return (
|
| 93 |
+
<group key={hand}>
|
| 94 |
+
{HAND_EDGES.map(([a, b]) => (
|
| 95 |
+
<Line
|
| 96 |
+
key={`${a}-${b}`}
|
| 97 |
+
points={[points[a], points[b]]}
|
| 98 |
+
color={color}
|
| 99 |
+
lineWidth={3}
|
| 100 |
+
/>
|
| 101 |
+
))}
|
| 102 |
+
{points.map((p, joint) => (
|
| 103 |
+
<mesh key={joint} position={p}>
|
| 104 |
+
<sphereGeometry args={[joint === 0 ? 0.012 : 0.004, 12, 8]} />
|
| 105 |
+
<meshBasicMaterial color={color} />
|
| 106 |
+
</mesh>
|
| 107 |
+
))}
|
| 108 |
+
{trail.length > 1 && (
|
| 109 |
+
<Line
|
| 110 |
+
points={trail}
|
| 111 |
+
color={color}
|
| 112 |
+
lineWidth={1.5}
|
| 113 |
+
transparent
|
| 114 |
+
opacity={0.5}
|
| 115 |
+
/>
|
| 116 |
+
)}
|
| 117 |
+
</group>
|
| 118 |
+
);
|
| 119 |
+
})}
|
| 120 |
+
</>
|
| 121 |
+
);
|
| 122 |
+
}
|
| 123 |
+
|
| 124 |
+
function RecordedCamera({
|
| 125 |
+
tracking,
|
| 126 |
+
index,
|
| 127 |
+
}: {
|
| 128 |
+
tracking: HandTracking;
|
| 129 |
+
index: number;
|
| 130 |
+
}) {
|
| 131 |
+
const paths = useMemo(
|
| 132 |
+
() => cameraPathRuns(tracking.frames, index),
|
| 133 |
+
[tracking.frames, index],
|
| 134 |
+
);
|
| 135 |
+
const pose = tracking.frames[index]?.cameraPose;
|
| 136 |
+
const origin = pose ? fluToScene(pose.position) : null;
|
| 137 |
+
const at = (pose: CameraPose, x: number, y: number, z: number): Point3 => {
|
| 138 |
+
const coordinate = (axis: 0 | 1 | 2) =>
|
| 139 |
+
pose.position[axis] +
|
| 140 |
+
pose.axes[0][axis] * x +
|
| 141 |
+
pose.axes[1][axis] * y +
|
| 142 |
+
pose.axes[2][axis] * z;
|
| 143 |
+
return fluToScene([coordinate(0), coordinate(1), coordinate(2)]);
|
| 144 |
+
};
|
| 145 |
+
const corners = pose
|
| 146 |
+
? [
|
| 147 |
+
at(pose, -0.055, -0.035, 0.08),
|
| 148 |
+
at(pose, 0.055, -0.035, 0.08),
|
| 149 |
+
at(pose, 0.055, 0.035, 0.08),
|
| 150 |
+
at(pose, -0.055, 0.035, 0.08),
|
| 151 |
+
]
|
| 152 |
+
: [];
|
| 153 |
+
return (
|
| 154 |
+
<>
|
| 155 |
+
{paths
|
| 156 |
+
.filter((path) => path.length > 1)
|
| 157 |
+
.map((path, i) => (
|
| 158 |
+
<Line key={i} points={path} color="#8391a4" lineWidth={1.5} />
|
| 159 |
+
))}
|
| 160 |
+
{origin && pose && (
|
| 161 |
+
<>
|
| 162 |
+
<mesh position={origin}>
|
| 163 |
+
<sphereGeometry args={[0.012, 12, 8]} />
|
| 164 |
+
<meshBasicMaterial color="#ffffff" />
|
| 165 |
+
</mesh>
|
| 166 |
+
{(["#ed6464", "#54ba7c", "#579cff"] as const).map((color, axis) => (
|
| 167 |
+
<Line
|
| 168 |
+
key={axis}
|
| 169 |
+
points={[
|
| 170 |
+
origin,
|
| 171 |
+
at(
|
| 172 |
+
pose,
|
| 173 |
+
axis === 0 ? 0.12 : 0,
|
| 174 |
+
axis === 1 ? 0.12 : 0,
|
| 175 |
+
axis === 2 ? 0.12 : 0,
|
| 176 |
+
),
|
| 177 |
+
]}
|
| 178 |
+
color={color}
|
| 179 |
+
lineWidth={2}
|
| 180 |
+
/>
|
| 181 |
+
))}
|
| 182 |
+
{corners.map((corner, i) => (
|
| 183 |
+
<Line
|
| 184 |
+
key={i}
|
| 185 |
+
points={[origin, corner, corners[(i + 1) % 4]]}
|
| 186 |
+
color="#8391a4"
|
| 187 |
+
lineWidth={1}
|
| 188 |
+
/>
|
| 189 |
+
))}
|
| 190 |
+
<Html position={origin} style={{ pointerEvents: "none" }}>
|
| 191 |
+
<span className="ml-3 text-xs text-fg-muted">Camera</span>
|
| 192 |
+
</Html>
|
| 193 |
+
</>
|
| 194 |
+
)}
|
| 195 |
+
</>
|
| 196 |
+
);
|
| 197 |
+
}
|
| 198 |
+
|
| 199 |
+
/** Follow translation while preserving the user's orbit angle and zoom. */
|
| 200 |
+
function FollowCamera({
|
| 201 |
+
frame,
|
| 202 |
+
enabled,
|
| 203 |
+
orbitRef,
|
| 204 |
+
}: {
|
| 205 |
+
frame: HandFrame | undefined;
|
| 206 |
+
enabled: boolean;
|
| 207 |
+
orbitRef: RefObject<ComponentRef<typeof OrbitControls> | null>;
|
| 208 |
+
}) {
|
| 209 |
+
const { camera, invalidate } = useThree();
|
| 210 |
+
const following = useRef(false);
|
| 211 |
+
useEffect(() => {
|
| 212 |
+
if (!enabled) {
|
| 213 |
+
following.current = false;
|
| 214 |
+
return;
|
| 215 |
+
}
|
| 216 |
+
if (!frame?.cameraPose) return;
|
| 217 |
+
const orbit = orbitRef.current;
|
| 218 |
+
if (!orbit) return;
|
| 219 |
+
const { position, axes } = frame.cameraPose;
|
| 220 |
+
const focus = new Vector3(
|
| 221 |
+
...fluToScene([
|
| 222 |
+
position[0] + axes[2][0] * 0.4,
|
| 223 |
+
position[1] + axes[2][1] * 0.4,
|
| 224 |
+
position[2] + axes[2][2] * 0.4,
|
| 225 |
+
]),
|
| 226 |
+
);
|
| 227 |
+
if (!following.current) {
|
| 228 |
+
camera.position
|
| 229 |
+
.sub(orbit.target)
|
| 230 |
+
.normalize()
|
| 231 |
+
.multiplyScalar(1.1)
|
| 232 |
+
.add(focus);
|
| 233 |
+
following.current = true;
|
| 234 |
+
} else camera.position.add(focus.clone().sub(orbit.target));
|
| 235 |
+
orbit.target.copy(focus);
|
| 236 |
+
orbit.update();
|
| 237 |
+
invalidate();
|
| 238 |
+
}, [camera, orbitRef, enabled, frame, invalidate]);
|
| 239 |
+
return null;
|
| 240 |
+
}
|
| 241 |
+
|
| 242 |
+
export default function HandReplay({
|
| 243 |
+
tracking,
|
| 244 |
+
videosInfo,
|
| 245 |
+
}: {
|
| 246 |
+
tracking: HandTracking;
|
| 247 |
+
videosInfo: VideoInfo[];
|
| 248 |
+
}) {
|
| 249 |
+
const { currentTime } = useTime();
|
| 250 |
+
const orbitRef = useRef<ComponentRef<typeof OrbitControls>>(null);
|
| 251 |
+
const theme = useTheme();
|
| 252 |
+
const [trails, setTrails] = useState(true);
|
| 253 |
+
const [follow, setFollow] = useState(false);
|
| 254 |
+
const index = handFrameIndex(tracking.frames, currentTime),
|
| 255 |
+
frame = tracking.frames[index];
|
| 256 |
+
const videos = useMemo(
|
| 257 |
+
() =>
|
| 258 |
+
[...videosInfo].sort(
|
| 259 |
+
(a, b) =>
|
| 260 |
+
Number(b.filename === tracking.cameraKey) -
|
| 261 |
+
Number(a.filename === tracking.cameraKey),
|
| 262 |
+
),
|
| 263 |
+
[videosInfo, tracking.cameraKey],
|
| 264 |
+
);
|
| 265 |
+
return (
|
| 266 |
+
<div className="space-y-4">
|
| 267 |
+
<div className="flex flex-wrap items-center justify-between gap-3 text-sm">
|
| 268 |
+
<div className="flex items-center gap-4">
|
| 269 |
+
{HAND_COLORS.map((color, hand) => (
|
| 270 |
+
<span key={hand} className="flex items-center gap-1.5">
|
| 271 |
+
<i
|
| 272 |
+
className="h-2 w-2 rounded-full"
|
| 273 |
+
style={{ backgroundColor: color }}
|
| 274 |
+
/>
|
| 275 |
+
{hand === 0 ? "Left" : "Right"} hand
|
| 276 |
+
</span>
|
| 277 |
+
))}
|
| 278 |
+
<span className="font-mono text-xs text-fg-muted">
|
| 279 |
+
Frame {index >= 0 ? index : "—"}
|
| 280 |
+
</span>
|
| 281 |
+
</div>
|
| 282 |
+
<div className="flex items-center gap-4">
|
| 283 |
+
{tracking.coordinateFrame === "recording" && (
|
| 284 |
+
<button
|
| 285 |
+
type="button"
|
| 286 |
+
className={`btn text-xs ${follow ? "btn-active" : ""}`}
|
| 287 |
+
aria-pressed={follow}
|
| 288 |
+
onClick={() => setFollow((value) => !value)}
|
| 289 |
+
>
|
| 290 |
+
Follow camera
|
| 291 |
+
</button>
|
| 292 |
+
)}
|
| 293 |
+
<label className="flex items-center gap-2 text-fg-soft">
|
| 294 |
+
<input
|
| 295 |
+
type="checkbox"
|
| 296 |
+
checked={trails}
|
| 297 |
+
onChange={(e) => setTrails(e.target.checked)}
|
| 298 |
+
/>
|
| 299 |
+
Wrist trails
|
| 300 |
+
</label>
|
| 301 |
+
</div>
|
| 302 |
+
</div>
|
| 303 |
+
<div className="grid gap-4 md:grid-cols-2 items-start">
|
| 304 |
+
<div className="panel overflow-hidden">
|
| 305 |
+
<div
|
| 306 |
+
className="relative h-[min(45vh,420px)] min-h-72"
|
| 307 |
+
aria-label="3D recorded hand and wrist positions"
|
| 308 |
+
>
|
| 309 |
+
<Canvas
|
| 310 |
+
frameloop="demand"
|
| 311 |
+
camera={{ position: [0.8, 0.6, 1.1], near: 0.001, far: 1000 }}
|
| 312 |
+
>
|
| 313 |
+
<color
|
| 314 |
+
attach="background"
|
| 315 |
+
args={[theme === "dark" ? "#151b24" : "#f3f5f7"]}
|
| 316 |
+
/>
|
| 317 |
+
<OrbitControls ref={orbitRef} makeDefault />
|
| 318 |
+
<Bounds fit clip margin={1.5}>
|
| 319 |
+
<RecordingBounds tracking={tracking} />
|
| 320 |
+
</Bounds>
|
| 321 |
+
<Grid
|
| 322 |
+
infiniteGrid
|
| 323 |
+
cellSize={0.1}
|
| 324 |
+
sectionSize={0.5}
|
| 325 |
+
fadeDistance={12}
|
| 326 |
+
cellColor={theme === "dark" ? "#354052" : "#cbd2dc"}
|
| 327 |
+
sectionColor={theme === "dark" ? "#536278" : "#9aa6b5"}
|
| 328 |
+
/>
|
| 329 |
+
<axesHelper args={[0.15]} />
|
| 330 |
+
<Hands tracking={tracking} index={index} trails={trails} />
|
| 331 |
+
<RecordedCamera tracking={tracking} index={index} />
|
| 332 |
+
<FollowCamera
|
| 333 |
+
frame={frame}
|
| 334 |
+
enabled={follow}
|
| 335 |
+
orbitRef={orbitRef}
|
| 336 |
+
/>
|
| 337 |
+
</Canvas>
|
| 338 |
+
{!frame?.[tracking.coordinateFrame].some(Boolean) && (
|
| 339 |
+
<div className="absolute inset-x-0 top-4 text-center pointer-events-none">
|
| 340 |
+
<span className="rounded bg-[var(--surface-0)] px-3 py-2 text-sm text-fg-muted">
|
| 341 |
+
No observed hand pose at this frame
|
| 342 |
+
</span>
|
| 343 |
+
</div>
|
| 344 |
+
)}
|
| 345 |
+
</div>
|
| 346 |
+
<div className="border-t border-line-subtle p-3 text-xs text-fg-muted space-y-1">
|
| 347 |
+
<p>Drag to orbit · scroll to zoom · grid cells 10 cm</p>
|
| 348 |
+
<p>
|
| 349 |
+
{tracking.coordinateFrame === "recording"
|
| 350 |
+
? "Recording frame anchored to the first saved camera pose, in meters. Camera paths and wrist trails break where poses are missing."
|
| 351 |
+
: "Segment-start frame, in meters. Coordinates and trails reset at each valid span. Camera transforms are unavailable."}
|
| 352 |
+
</p>
|
| 353 |
+
{(["Left", "Right"] as const).map((side, hand) => (
|
| 354 |
+
<p key={side} className="font-mono">
|
| 355 |
+
{side} wrist:{" "}
|
| 356 |
+
{frame?.[tracking.coordinateFrame][hand]?.[0]
|
| 357 |
+
.map((n) => n.toFixed(3))
|
| 358 |
+
.join(", ") ?? "unobserved"}{" "}
|
| 359 |
+
{frame?.[tracking.coordinateFrame][hand]
|
| 360 |
+
? "(forward, left, up)"
|
| 361 |
+
: ""}
|
| 362 |
+
</p>
|
| 363 |
+
))}
|
| 364 |
+
</div>
|
| 365 |
+
</div>
|
| 366 |
+
<div className="space-y-3">
|
| 367 |
+
<SimpleVideosPlayer videosInfo={videos} handTracking={tracking} />
|
| 368 |
+
{tracking.overlayIssue && (
|
| 369 |
+
<p className="text-sm text-fg-muted">{tracking.overlayIssue}</p>
|
| 370 |
+
)}
|
| 371 |
+
</div>
|
| 372 |
+
</div>
|
| 373 |
+
<PlaybackBar />
|
| 374 |
+
</div>
|
| 375 |
+
);
|
| 376 |
+
}
|
src/components/simple-videos-player.tsx
CHANGED
|
@@ -7,11 +7,10 @@ import type { VideoInfo } from "@/types";
|
|
| 7 |
import { proxyHfUrl } from "@/utils/auth";
|
| 8 |
import { VideoOverlayCanvas } from "./video-overlay-canvas";
|
| 9 |
import { ColormappedVideo } from "./colormapped-video";
|
|
|
|
|
|
|
| 10 |
|
| 11 |
-
const THRESHOLDS = {
|
| 12 |
-
VIDEO_SYNC_TOLERANCE: 0.2,
|
| 13 |
-
VIDEO_SEGMENT_BOUNDARY: 0.05,
|
| 14 |
-
};
|
| 15 |
|
| 16 |
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
| 17 |
|
|
@@ -32,6 +31,7 @@ const SUPPORTS_RVFC =
|
|
| 32 |
type VideoPlayerProps = {
|
| 33 |
videosInfo: VideoInfo[];
|
| 34 |
onVideosReady?: () => void;
|
|
|
|
| 35 |
};
|
| 36 |
|
| 37 |
const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
|
|
@@ -39,7 +39,9 @@ const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
|
|
| 39 |
export const SimpleVideosPlayer = ({
|
| 40 |
videosInfo,
|
| 41 |
onVideosReady,
|
|
|
|
| 42 |
}: VideoPlayerProps) => {
|
|
|
|
| 43 |
const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
|
| 44 |
useTime();
|
| 45 |
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
|
@@ -126,6 +128,20 @@ export const SimpleVideosPlayer = ({
|
|
| 126 |
// context's version moves past this, an external seek happened and we
|
| 127 |
// need to drive every video to the new position.
|
| 128 |
const lastSeekVersionRef = useRef(externalSeekVersion);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 129 |
|
| 130 |
// Mirror firstVisibleIdx into a ref so the videos-ready effect doesn't have
|
| 131 |
// to depend on it. If it did, hiding the first camera would tear the whole
|
|
@@ -166,6 +182,7 @@ export const SimpleVideosPlayer = ({
|
|
| 166 |
if (resolved) return;
|
| 167 |
resolved = true;
|
| 168 |
setVideosReady(true);
|
|
|
|
| 169 |
onVideosReadyRef.current?.();
|
| 170 |
setIsPlaying(true);
|
| 171 |
};
|
|
@@ -426,10 +443,7 @@ export const SimpleVideosPlayer = ({
|
|
| 426 |
targetTime = (info.segmentStart ?? 0) + currentTime;
|
| 427 |
}
|
| 428 |
|
| 429 |
-
if (
|
| 430 |
-
Math.abs(video.currentTime - targetTime) >
|
| 431 |
-
THRESHOLDS.VIDEO_SYNC_TOLERANCE
|
| 432 |
-
) {
|
| 433 |
video.currentTime = targetTime;
|
| 434 |
}
|
| 435 |
});
|
|
@@ -437,6 +451,29 @@ export const SimpleVideosPlayer = ({
|
|
| 437 |
|
| 438 |
return (
|
| 439 |
<>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 440 |
{/* Hidden videos menu */}
|
| 441 |
{hiddenVideos.length > 0 && (
|
| 442 |
<div className="relative mb-4">
|
|
@@ -573,6 +610,15 @@ export const SimpleVideosPlayer = ({
|
|
| 573 |
videoEl={videoEls[idx] ?? null}
|
| 574 |
cameraKey={info.filename}
|
| 575 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 576 |
</div>
|
| 577 |
</div>
|
| 578 |
</div>
|
|
|
|
| 7 |
import { proxyHfUrl } from "@/utils/auth";
|
| 8 |
import { VideoOverlayCanvas } from "./video-overlay-canvas";
|
| 9 |
import { ColormappedVideo } from "./colormapped-video";
|
| 10 |
+
import { HandOverlayCanvas } from "./hand-overlay-canvas";
|
| 11 |
+
import type { HandTracking } from "@/lib/hand-tracking";
|
| 12 |
|
| 13 |
+
const THRESHOLDS = { VIDEO_SEGMENT_BOUNDARY: 0.05 };
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
const VIDEO_READY_TIMEOUT_MS = 10_000;
|
| 16 |
|
|
|
|
| 31 |
type VideoPlayerProps = {
|
| 32 |
videosInfo: VideoInfo[];
|
| 33 |
onVideosReady?: () => void;
|
| 34 |
+
handTracking?: HandTracking;
|
| 35 |
};
|
| 36 |
|
| 37 |
const videoEventCleanup = new WeakMap<HTMLVideoElement, () => void>();
|
|
|
|
| 39 |
export const SimpleVideosPlayer = ({
|
| 40 |
videosInfo,
|
| 41 |
onVideosReady,
|
| 42 |
+
handTracking,
|
| 43 |
}: VideoPlayerProps) => {
|
| 44 |
+
const [showHands, setShowHands] = React.useState(true);
|
| 45 |
const { currentTime, seek, externalSeekVersion, isPlaying, setIsPlaying } =
|
| 46 |
useTime();
|
| 47 |
const videoRefs = useRef<(HTMLVideoElement | null)[]>([]);
|
|
|
|
| 128 |
// context's version moves past this, an external seek happened and we
|
| 129 |
// need to drive every video to the new position.
|
| 130 |
const lastSeekVersionRef = useRef(externalSeekVersion);
|
| 131 |
+
// Media may take seconds to load. Keep the latest external seek, including
|
| 132 |
+
// the initial URL timestamp and seeks made while loading, until it is ready.
|
| 133 |
+
const pendingSeekRef = useRef({
|
| 134 |
+
version: externalSeekVersion,
|
| 135 |
+
time: currentTime,
|
| 136 |
+
});
|
| 137 |
+
useEffect(() => {
|
| 138 |
+
if (pendingSeekRef.current.version !== externalSeekVersion) {
|
| 139 |
+
pendingSeekRef.current = {
|
| 140 |
+
version: externalSeekVersion,
|
| 141 |
+
time: currentTime,
|
| 142 |
+
};
|
| 143 |
+
}
|
| 144 |
+
}, [externalSeekVersion, currentTime]);
|
| 145 |
|
| 146 |
// Mirror firstVisibleIdx into a ref so the videos-ready effect doesn't have
|
| 147 |
// to depend on it. If it did, hiding the first camera would tear the whole
|
|
|
|
| 182 |
if (resolved) return;
|
| 183 |
resolved = true;
|
| 184 |
setVideosReady(true);
|
| 185 |
+
seek(pendingSeekRef.current.time);
|
| 186 |
onVideosReadyRef.current?.();
|
| 187 |
setIsPlaying(true);
|
| 188 |
};
|
|
|
|
| 443 |
targetTime = (info.segmentStart ?? 0) + currentTime;
|
| 444 |
}
|
| 445 |
|
| 446 |
+
if (video.currentTime !== targetTime) {
|
|
|
|
|
|
|
|
|
|
| 447 |
video.currentTime = targetTime;
|
| 448 |
}
|
| 449 |
});
|
|
|
|
| 451 |
|
| 452 |
return (
|
| 453 |
<>
|
| 454 |
+
{handTracking && (
|
| 455 |
+
<div className="flex flex-wrap items-center gap-3 text-xs text-fg-muted">
|
| 456 |
+
<label className="flex items-center gap-2 text-fg-soft">
|
| 457 |
+
<input
|
| 458 |
+
type="checkbox"
|
| 459 |
+
checked={showHands}
|
| 460 |
+
disabled={!handTracking.cameraKey || !handTracking.calibration}
|
| 461 |
+
onChange={(e) => setShowHands(e.target.checked)}
|
| 462 |
+
/>
|
| 463 |
+
Hand overlay
|
| 464 |
+
</label>
|
| 465 |
+
<span>
|
| 466 |
+
{handTracking.calibration && handTracking.cameraKey
|
| 467 |
+
? handTracking.cameraKey
|
| 468 |
+
: handTracking.overlayIssue}
|
| 469 |
+
</span>
|
| 470 |
+
{handTracking.calibration?.estimated && (
|
| 471 |
+
<span>
|
| 472 |
+
Estimated camera calibration · metric accuracy unvalidated
|
| 473 |
+
</span>
|
| 474 |
+
)}
|
| 475 |
+
</div>
|
| 476 |
+
)}
|
| 477 |
{/* Hidden videos menu */}
|
| 478 |
{hiddenVideos.length > 0 && (
|
| 479 |
<div className="relative mb-4">
|
|
|
|
| 610 |
videoEl={videoEls[idx] ?? null}
|
| 611 |
cameraKey={info.filename}
|
| 612 |
/>
|
| 613 |
+
{handTracking && (
|
| 614 |
+
<HandOverlayCanvas
|
| 615 |
+
video={videoEls[idx] ?? null}
|
| 616 |
+
tracking={handTracking}
|
| 617 |
+
cameraKey={info.filename}
|
| 618 |
+
segmentStart={info.isSegmented ? info.segmentStart : 0}
|
| 619 |
+
enabled={showHands}
|
| 620 |
+
/>
|
| 621 |
+
)}
|
| 622 |
</div>
|
| 623 |
</div>
|
| 624 |
</div>
|
src/components/urdf-viewer.tsx
CHANGED
|
@@ -18,10 +18,8 @@ import {
|
|
| 18 |
import * as THREE from "three";
|
| 19 |
import URDFLoader from "urdf-loader";
|
| 20 |
import type { URDFRobot } from "urdf-loader";
|
|
|
|
| 21 |
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
| 22 |
-
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";
|
| 23 |
-
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
|
| 24 |
-
import { MeshoptDecoder } from "three/examples/jsm/libs/meshopt_decoder.module.js";
|
| 25 |
import { Line2 } from "three/examples/jsm/lines/Line2.js";
|
| 26 |
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
|
| 27 |
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
|
|
@@ -59,136 +57,6 @@ function loadStlGeometry(url: string): Promise<THREE.BufferGeometry> {
|
|
| 59 |
return loading;
|
| 60 |
}
|
| 61 |
|
| 62 |
-
/**
|
| 63 |
-
* Rebuilds every mesh with a MeshStandardMaterial bucketed into one of three archetypes (carbon-black,
|
| 64 |
-
* brushed metal, off-white paint) by the original base-colour lightness. `flatShading` is for the GLB
|
| 65 |
-
* meshes, which carry no normals.
|
| 66 |
-
*/
|
| 67 |
-
function applyOpenArmMaterials(root: THREE.Object3D, flatShading: boolean) {
|
| 68 |
-
root.traverse((child) => {
|
| 69 |
-
if (!(child instanceof THREE.Mesh) || !child.material) return;
|
| 70 |
-
|
| 71 |
-
const originals = Array.isArray(child.material)
|
| 72 |
-
? child.material
|
| 73 |
-
: [child.material];
|
| 74 |
-
|
| 75 |
-
const rebuilt = originals.map((orig) => {
|
| 76 |
-
const srcColor =
|
| 77 |
-
(orig as THREE.MeshStandardMaterial).color ??
|
| 78 |
-
new THREE.Color("#c0c4cc");
|
| 79 |
-
const hsl = { h: 0, s: 0, l: 0 };
|
| 80 |
-
srcColor.getHSL(hsl);
|
| 81 |
-
|
| 82 |
-
// Archetype classification by original lightness.
|
| 83 |
-
let color: THREE.Color;
|
| 84 |
-
let metalness: number;
|
| 85 |
-
let roughness: number;
|
| 86 |
-
let envMapIntensity: number;
|
| 87 |
-
if (hsl.l < 0.3) {
|
| 88 |
-
// Carbon / anodised structural parts
|
| 89 |
-
color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
|
| 90 |
-
metalness = 0.15;
|
| 91 |
-
roughness = 0.75;
|
| 92 |
-
envMapIntensity = 0.6;
|
| 93 |
-
} else if (hsl.l < 0.7) {
|
| 94 |
-
// Brushed metal joint collars / accents
|
| 95 |
-
color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
|
| 96 |
-
metalness = 0.75;
|
| 97 |
-
roughness = 0.35;
|
| 98 |
-
envMapIntensity = 1.1;
|
| 99 |
-
} else {
|
| 100 |
-
// Off-white painted plates
|
| 101 |
-
color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
|
| 102 |
-
metalness = 0.1;
|
| 103 |
-
roughness = 0.5;
|
| 104 |
-
envMapIntensity = 0.9;
|
| 105 |
-
}
|
| 106 |
-
|
| 107 |
-
const mat = new THREE.MeshStandardMaterial({
|
| 108 |
-
color,
|
| 109 |
-
metalness,
|
| 110 |
-
roughness,
|
| 111 |
-
envMapIntensity,
|
| 112 |
-
side: THREE.DoubleSide,
|
| 113 |
-
flatShading,
|
| 114 |
-
});
|
| 115 |
-
orig.dispose?.();
|
| 116 |
-
return mat;
|
| 117 |
-
});
|
| 118 |
-
|
| 119 |
-
child.material = Array.isArray(child.material) ? rebuilt : rebuilt[0];
|
| 120 |
-
child.castShadow = true;
|
| 121 |
-
child.receiveShadow = true;
|
| 122 |
-
});
|
| 123 |
-
}
|
| 124 |
-
|
| 125 |
-
/// One download and parse per DAE file per session: the OpenArm URDF references finger.dae 4 times
|
| 126 |
-
/// and each arm link twice, and the viewer remounts on every visit to the 3D Replay tab.
|
| 127 |
-
const daeSceneCache = new Map<string, Promise<THREE.Object3D>>();
|
| 128 |
-
|
| 129 |
-
function loadDaeScene(url: string): Promise<THREE.Object3D> {
|
| 130 |
-
let loading = daeSceneCache.get(url);
|
| 131 |
-
if (!loading) {
|
| 132 |
-
loading = new ColladaLoader()
|
| 133 |
-
.loadAsync(url)
|
| 134 |
-
.then((collada) => {
|
| 135 |
-
// DAE (Collada) files — ColladaLoader yields whatever the .dae author
|
| 136 |
-
// baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
|
| 137 |
-
// OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
|
| 138 |
-
// lights caused the scene to look pure-white everywhere regardless of
|
| 139 |
-
// our own lighting, and the flat materials looked cartoonish. We strip
|
| 140 |
-
// both and rebuild every mesh with a MeshStandardMaterial bucketed into
|
| 141 |
-
// one of three archetypes (carbon-black, brushed metal, off-white paint)
|
| 142 |
-
// based on the original base-color lightness.
|
| 143 |
-
if (url.includes("openarm")) {
|
| 144 |
-
const strayLights: THREE.Object3D[] = [];
|
| 145 |
-
collada.scene.traverse((child) => {
|
| 146 |
-
if (
|
| 147 |
-
(child as THREE.Light).isLight &&
|
| 148 |
-
!(child instanceof THREE.AmbientLight)
|
| 149 |
-
) {
|
| 150 |
-
strayLights.push(child);
|
| 151 |
-
}
|
| 152 |
-
});
|
| 153 |
-
for (const l of strayLights) l.parent?.remove(l);
|
| 154 |
-
|
| 155 |
-
applyOpenArmMaterials(collada.scene, false);
|
| 156 |
-
}
|
| 157 |
-
return collada.scene;
|
| 158 |
-
})
|
| 159 |
-
.catch((err) => {
|
| 160 |
-
daeSceneCache.delete(url);
|
| 161 |
-
throw err;
|
| 162 |
-
});
|
| 163 |
-
daeSceneCache.set(url, loading);
|
| 164 |
-
}
|
| 165 |
-
return loading;
|
| 166 |
-
}
|
| 167 |
-
|
| 168 |
-
/// The bucket holds meshopt-compressed GLB copies of the SO-101 and OpenArm meshes, about 16x and 20x
|
| 169 |
-
/// smaller than the STL/DAE originals. They carry no normals: the originals' are flat per face, and
|
| 170 |
-
/// flatShading derives the same ones.
|
| 171 |
-
const glbSceneCache = new Map<string, Promise<THREE.Object3D>>();
|
| 172 |
-
|
| 173 |
-
function loadGlbScene(url: string): Promise<THREE.Object3D> {
|
| 174 |
-
let loading = glbSceneCache.get(url);
|
| 175 |
-
if (!loading) {
|
| 176 |
-
loading = new GLTFLoader()
|
| 177 |
-
.setMeshoptDecoder(MeshoptDecoder)
|
| 178 |
-
.loadAsync(url)
|
| 179 |
-
.then((gltf) => {
|
| 180 |
-
if (url.includes("openarm")) applyOpenArmMaterials(gltf.scene, true);
|
| 181 |
-
return gltf.scene;
|
| 182 |
-
})
|
| 183 |
-
.catch((err) => {
|
| 184 |
-
glbSceneCache.delete(url);
|
| 185 |
-
throw err;
|
| 186 |
-
});
|
| 187 |
-
glbSceneCache.set(url, loading);
|
| 188 |
-
}
|
| 189 |
-
return loading;
|
| 190 |
-
}
|
| 191 |
-
|
| 192 |
// URDFs + meshes are hosted in the Hub bucket at
|
| 193 |
// https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
|
| 194 |
// relative mesh paths against the URDF's own URL, so the bucket layout
|
|
@@ -222,34 +90,90 @@ function loadUrdfText(url: string): Promise<string> {
|
|
| 222 |
return loading;
|
| 223 |
}
|
| 224 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
/**
|
| 226 |
* Starts downloading a robot's visual meshes into the caches. The viewer only mounts once the episode
|
| 227 |
* data has loaded, and the meshes used to start downloading only then; calling this earlier overlaps
|
| 228 |
* the two.
|
| 229 |
*/
|
| 230 |
export function prefetchRobotModel(robotType: string | null) {
|
| 231 |
-
const { urdfUrl } = getRobotConfig(robotType);
|
| 232 |
if (prefetchedUrdfs.has(urdfUrl)) return;
|
| 233 |
prefetchedUrdfs.add(urdfUrl);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 234 |
const base = THREE.LoaderUtils.extractUrlBase(urdfUrl);
|
| 235 |
loadUrdfText(urdfUrl)
|
| 236 |
.then((text) => {
|
| 237 |
const doc = new DOMParser().parseFromString(text, "application/xml");
|
| 238 |
for (const mesh of doc.querySelectorAll("visual mesh")) {
|
| 239 |
const filename = mesh.getAttribute("filename");
|
| 240 |
-
/// Same resolution URDFLoader applies to relative paths; package://
|
| 241 |
if (!filename || filename.startsWith("package://")) continue;
|
| 242 |
-
|
| 243 |
-
if (lower.endsWith(".glb"))
|
| 244 |
-
loadGlbScene(base + filename).catch(() => {});
|
| 245 |
-
else if (lower.endsWith(".stl"))
|
| 246 |
loadStlGeometry(base + filename).catch(() => {});
|
| 247 |
}
|
| 248 |
})
|
| 249 |
.catch(() => prefetchedUrdfs.delete(urdfUrl));
|
| 250 |
}
|
| 251 |
|
| 252 |
-
function getRobotConfig(robotType: string | null) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 253 |
const lower = (robotType ?? "").toLowerCase();
|
| 254 |
if (lower.includes("g1") || lower.includes("unitree")) {
|
| 255 |
return { urdfUrl: `${URDF_BASE_URL}/g1/g1_body29_hand14.urdf`, scale: 1 };
|
|
@@ -257,11 +181,13 @@ function getRobotConfig(robotType: string | null) {
|
|
| 257 |
if (lower.includes("openarm")) {
|
| 258 |
return {
|
| 259 |
urdfUrl: `${URDF_BASE_URL}/openarm/openarm_bimanual_web.urdf`,
|
|
|
|
| 260 |
scale: 3,
|
| 261 |
};
|
| 262 |
}
|
| 263 |
return {
|
| 264 |
urdfUrl: `${URDF_BASE_URL}/so101/so101_new_calib_web.urdf`,
|
|
|
|
| 265 |
scale: 10,
|
| 266 |
};
|
| 267 |
}
|
|
@@ -353,11 +279,14 @@ function autoMatchJoints(
|
|
| 353 |
continue;
|
| 354 |
}
|
| 355 |
|
| 356 |
-
// OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+)
|
|
|
|
| 357 |
const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
|
| 358 |
if (armMatch) {
|
| 359 |
-
const pattern =
|
| 360 |
-
|
|
|
|
|
|
|
| 361 |
if (idx >= 0) {
|
| 362 |
mapping[jointName] = columnKeys[idx];
|
| 363 |
continue;
|
|
@@ -430,6 +359,7 @@ const MAX_FIT_WAIT_FRAMES = 30;
|
|
| 430 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 431 |
function RobotScene({
|
| 432 |
urdfUrl,
|
|
|
|
| 433 |
jointValues,
|
| 434 |
onJointsLoaded,
|
| 435 |
trailEnabled,
|
|
@@ -437,6 +367,7 @@ function RobotScene({
|
|
| 437 |
scale,
|
| 438 |
}: {
|
| 439 |
urdfUrl: string;
|
|
|
|
| 440 |
jointValues: Record<string, number>;
|
| 441 |
onJointsLoaded: (names: string[]) => void;
|
| 442 |
trailEnabled: boolean;
|
|
@@ -537,20 +468,22 @@ function RobotScene({
|
|
| 537 |
// `if (obj instanceof THREE.Mesh) obj.material = material;`
|
| 538 |
// on every mesh we hand back — overwriting our PBR material with the
|
| 539 |
// URDF's <material rgba="...">-derived MeshPhongMaterial. Wrapping the
|
| 540 |
-
// returned mesh in a Group dodges that check
|
| 541 |
-
// already avoids it) so our carefully-tuned materials survive.
|
| 542 |
const wrapForUrdf = (obj: THREE.Object3D): THREE.Object3D => {
|
| 543 |
const group = new THREE.Group();
|
| 544 |
group.add(obj);
|
| 545 |
return group;
|
| 546 |
};
|
| 547 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
| 548 |
-
|
| 549 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 550 |
let color = "#9ba1ab";
|
| 551 |
let metalness = 0.1;
|
| 552 |
let roughness = 0.5;
|
| 553 |
-
let side: THREE.Side = THREE.FrontSide;
|
| 554 |
if (isG1) {
|
| 555 |
const lower = url.toLowerCase();
|
| 556 |
const isWhitePart =
|
|
@@ -563,54 +496,9 @@ function RobotScene({
|
|
| 563 |
color = isWhitePart ? "#9ca3af" : "#1f2937";
|
| 564 |
metalness = 0.25;
|
| 565 |
roughness = 0.6;
|
| 566 |
-
} else if (url.includes("sts3215")) {
|
| 567 |
-
// SO-arm / any STL servo housing — carbon-black archetype
|
| 568 |
-
color = "#171a20";
|
| 569 |
-
metalness = 0.15;
|
| 570 |
-
roughness = 0.75;
|
| 571 |
-
} else if (isOpenArm) {
|
| 572 |
-
color = url.includes("body_link0") ? "#3a3a4a" : "#f5f5f5";
|
| 573 |
-
metalness = 0.15;
|
| 574 |
-
roughness = 0.6;
|
| 575 |
-
side = THREE.DoubleSide;
|
| 576 |
}
|
| 577 |
-
return new THREE.MeshStandardMaterial({
|
| 578 |
-
color,
|
| 579 |
-
metalness,
|
| 580 |
-
roughness,
|
| 581 |
-
side,
|
| 582 |
-
flatShading,
|
| 583 |
-
});
|
| 584 |
};
|
| 585 |
-
const makeMesh = (geometry: THREE.BufferGeometry) =>
|
| 586 |
-
new THREE.Mesh(geometry, makeMaterial(false));
|
| 587 |
-
if (url.endsWith(".glb")) {
|
| 588 |
-
mgr.itemStart(url);
|
| 589 |
-
loadGlbScene(url)
|
| 590 |
-
.then((glbScene) => {
|
| 591 |
-
const obj = glbScene.clone();
|
| 592 |
-
if (!isOpenArm) {
|
| 593 |
-
obj.traverse((child) => {
|
| 594 |
-
if (child instanceof THREE.Mesh)
|
| 595 |
-
child.material = makeMaterial(true);
|
| 596 |
-
});
|
| 597 |
-
}
|
| 598 |
-
onLoad(obj);
|
| 599 |
-
})
|
| 600 |
-
.catch((err) => onLoad(new THREE.Object3D(), err as Error))
|
| 601 |
-
.finally(() => mgr.itemEnd(url));
|
| 602 |
-
return;
|
| 603 |
-
}
|
| 604 |
-
if (url.endsWith(".dae")) {
|
| 605 |
-
/// URDFLoader re-parents and re-orients what it is handed, so it gets a clone (sharing
|
| 606 |
-
/// geometry and materials) and the cached scene stays intact.
|
| 607 |
-
mgr.itemStart(url);
|
| 608 |
-
loadDaeScene(url)
|
| 609 |
-
.then((daeScene) => onLoad(daeScene.clone()))
|
| 610 |
-
.catch((err) => onLoad(new THREE.Object3D(), err as Error))
|
| 611 |
-
.finally(() => mgr.itemEnd(url));
|
| 612 |
-
return;
|
| 613 |
-
}
|
| 614 |
// STL files — apply final PBR materials directly here. We used to do a
|
| 615 |
// post-load archetype rebuild in manager.onLoad, but STLLoader calls
|
| 616 |
// `manager.itemEnd` *before* our Promise resolves — so when the last
|
|
@@ -628,7 +516,9 @@ function RobotScene({
|
|
| 628 |
/// with every mesh prefetched it would otherwise never fire.
|
| 629 |
mgr.itemStart(url);
|
| 630 |
loadStlGeometry(url)
|
| 631 |
-
.then((geometry) =>
|
|
|
|
|
|
|
| 632 |
.catch((err) => onLoad(new THREE.Object3D(), err as Error))
|
| 633 |
.finally(() => mgr.itemEnd(url));
|
| 634 |
};
|
|
@@ -654,6 +544,7 @@ function RobotScene({
|
|
| 654 |
|
| 655 |
loader.workingPath = THREE.LoaderUtils.extractUrlBase(urdfUrl);
|
| 656 |
let cancelled = false;
|
|
|
|
| 657 |
manager.itemStart(urdfUrl);
|
| 658 |
loadUrdfText(urdfUrl)
|
| 659 |
.then((text) => {
|
|
@@ -700,11 +591,24 @@ function RobotScene({
|
|
| 700 |
j.jointType === "prismatic",
|
| 701 |
)
|
| 702 |
.map((j) => j.name);
|
| 703 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 704 |
})
|
| 705 |
.catch((err) => {
|
| 706 |
if (cancelled) return;
|
| 707 |
-
console.error("Error loading
|
| 708 |
setError(String(err));
|
| 709 |
})
|
| 710 |
.finally(() => {
|
|
@@ -716,9 +620,10 @@ function RobotScene({
|
|
| 716 |
scene.remove(robotRef.current);
|
| 717 |
robotRef.current = null;
|
| 718 |
}
|
|
|
|
| 719 |
tipLinksRef.current = [];
|
| 720 |
};
|
| 721 |
-
}, [urdfUrl, scale, scene, onJointsLoaded, ensureTrails, invalidate]);
|
| 722 |
|
| 723 |
const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
|
| 724 |
|
|
@@ -811,7 +716,7 @@ function RobotScene({
|
|
| 811 |
if (error)
|
| 812 |
return (
|
| 813 |
<Html center>
|
| 814 |
-
<span className="text-red-400">Failed to load
|
| 815 |
</Html>
|
| 816 |
);
|
| 817 |
return null;
|
|
@@ -921,7 +826,7 @@ export default function URDFViewer({
|
|
| 921 |
() => getRobotConfig(datasetInfo.robot_type),
|
| 922 |
[datasetInfo.robot_type],
|
| 923 |
);
|
| 924 |
-
const { urdfUrl, scale } = robotConfig;
|
| 925 |
const isG1 = urdfUrl.includes("g1");
|
| 926 |
const isOpenArm = urdfUrl.includes("openarm");
|
| 927 |
const repoId = org && dataset ? `${org}/${dataset}` : null;
|
|
@@ -1222,6 +1127,7 @@ export default function URDFViewer({
|
|
| 1222 |
</mesh>
|
| 1223 |
<RobotScene
|
| 1224 |
urdfUrl={urdfUrl}
|
|
|
|
| 1225 |
jointValues={jointValues}
|
| 1226 |
onJointsLoaded={onJointsLoaded}
|
| 1227 |
trailEnabled={trailEnabled}
|
|
|
|
| 18 |
import * as THREE from "three";
|
| 19 |
import URDFLoader from "urdf-loader";
|
| 20 |
import type { URDFRobot } from "urdf-loader";
|
| 21 |
+
import { loadUsd } from "@huggingface/usd-web-viewer";
|
| 22 |
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";
|
|
|
|
|
|
|
|
|
|
| 23 |
import { Line2 } from "three/examples/jsm/lines/Line2.js";
|
| 24 |
import { LineMaterial } from "three/examples/jsm/lines/LineMaterial.js";
|
| 25 |
import { LineGeometry } from "three/examples/jsm/lines/LineGeometry.js";
|
|
|
|
| 57 |
return loading;
|
| 58 |
}
|
| 59 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
// URDFs + meshes are hosted in the Hub bucket at
|
| 61 |
// https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
|
| 62 |
// relative mesh paths against the URDF's own URL, so the bucket layout
|
|
|
|
| 90 |
return loading;
|
| 91 |
}
|
| 92 |
|
| 93 |
+
/// USD models of the robots with their URDF's joint and link names, link frames and zero pose: the
|
| 94 |
+
/// URDF moves the robot and the USD draws it.
|
| 95 |
+
const USD_BASE_URL =
|
| 96 |
+
"https://huggingface.co/datasets/lerobot/robot-usds/resolve/main";
|
| 97 |
+
|
| 98 |
+
/// Kept for the session like the URDF text: the viewer remounts on every visit to the 3D Replay tab.
|
| 99 |
+
const usdBlobCache = new Map<string, Promise<Blob>>();
|
| 100 |
+
|
| 101 |
+
function loadUsdBlob(url: string): Promise<Blob> {
|
| 102 |
+
let loading = usdBlobCache.get(url);
|
| 103 |
+
if (!loading) {
|
| 104 |
+
loading = fetch(url)
|
| 105 |
+
.then((res) => {
|
| 106 |
+
if (!res.ok)
|
| 107 |
+
throw new Error(`Failed to load USD ${url} (HTTP ${res.status})`);
|
| 108 |
+
return res.blob();
|
| 109 |
+
})
|
| 110 |
+
.catch((err) => {
|
| 111 |
+
usdBlobCache.delete(url);
|
| 112 |
+
throw err;
|
| 113 |
+
});
|
| 114 |
+
usdBlobCache.set(url, loading);
|
| 115 |
+
}
|
| 116 |
+
return loading;
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
/**
|
| 120 |
+
* Moves each USD mesh under the URDF link its prim path names, where the URDF's zero pose puts it.
|
| 121 |
+
* USD stage space is the URDF's root frame, so the robot's joints then move the meshes.
|
| 122 |
+
*/
|
| 123 |
+
function attachUsdMeshes(robot: URDFRobot, usdRoot: THREE.Object3D) {
|
| 124 |
+
for (const name of Object.keys(robot.joints)) robot.setJointValue(name, 0);
|
| 125 |
+
robot.updateMatrixWorld(true);
|
| 126 |
+
for (const mesh of [...usdRoot.children]) {
|
| 127 |
+
const segments = mesh.name.split("/");
|
| 128 |
+
let link: THREE.Object3D = robot;
|
| 129 |
+
for (let i = segments.length - 1; i >= 0; i--) {
|
| 130 |
+
if (robot.links[segments[i]]) {
|
| 131 |
+
link = robot.links[segments[i]];
|
| 132 |
+
break;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
mesh.matrix
|
| 136 |
+
.premultiply(robot.matrixWorld)
|
| 137 |
+
.premultiply(link.matrixWorld.clone().invert());
|
| 138 |
+
link.add(mesh);
|
| 139 |
+
}
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
/**
|
| 143 |
* Starts downloading a robot's visual meshes into the caches. The viewer only mounts once the episode
|
| 144 |
* data has loaded, and the meshes used to start downloading only then; calling this earlier overlaps
|
| 145 |
* the two.
|
| 146 |
*/
|
| 147 |
export function prefetchRobotModel(robotType: string | null) {
|
| 148 |
+
const { urdfUrl, usdUrl } = getRobotConfig(robotType);
|
| 149 |
if (prefetchedUrdfs.has(urdfUrl)) return;
|
| 150 |
prefetchedUrdfs.add(urdfUrl);
|
| 151 |
+
if (usdUrl) {
|
| 152 |
+
Promise.all([loadUrdfText(urdfUrl), loadUsdBlob(usdUrl)]).catch(() =>
|
| 153 |
+
prefetchedUrdfs.delete(urdfUrl),
|
| 154 |
+
);
|
| 155 |
+
return;
|
| 156 |
+
}
|
| 157 |
const base = THREE.LoaderUtils.extractUrlBase(urdfUrl);
|
| 158 |
loadUrdfText(urdfUrl)
|
| 159 |
.then((text) => {
|
| 160 |
const doc = new DOMParser().parseFromString(text, "application/xml");
|
| 161 |
for (const mesh of doc.querySelectorAll("visual mesh")) {
|
| 162 |
const filename = mesh.getAttribute("filename");
|
| 163 |
+
/// Same resolution URDFLoader applies to relative paths; package:// meshes are left to it.
|
| 164 |
if (!filename || filename.startsWith("package://")) continue;
|
| 165 |
+
if (filename.toLowerCase().endsWith(".stl"))
|
|
|
|
|
|
|
|
|
|
| 166 |
loadStlGeometry(base + filename).catch(() => {});
|
| 167 |
}
|
| 168 |
})
|
| 169 |
.catch(() => prefetchedUrdfs.delete(urdfUrl));
|
| 170 |
}
|
| 171 |
|
| 172 |
+
function getRobotConfig(robotType: string | null): {
|
| 173 |
+
urdfUrl: string;
|
| 174 |
+
usdUrl?: string;
|
| 175 |
+
scale: number;
|
| 176 |
+
} {
|
| 177 |
const lower = (robotType ?? "").toLowerCase();
|
| 178 |
if (lower.includes("g1") || lower.includes("unitree")) {
|
| 179 |
return { urdfUrl: `${URDF_BASE_URL}/g1/g1_body29_hand14.urdf`, scale: 1 };
|
|
|
|
| 181 |
if (lower.includes("openarm")) {
|
| 182 |
return {
|
| 183 |
urdfUrl: `${URDF_BASE_URL}/openarm/openarm_bimanual_web.urdf`,
|
| 184 |
+
usdUrl: `${USD_BASE_URL}/openarm_bimanual/simready_usd/openarm_bimanual.usd`,
|
| 185 |
scale: 3,
|
| 186 |
};
|
| 187 |
}
|
| 188 |
return {
|
| 189 |
urdfUrl: `${URDF_BASE_URL}/so101/so101_new_calib_web.urdf`,
|
| 190 |
+
usdUrl: `${USD_BASE_URL}/so101_new_calib/simready_usd/so101_new_calib.usd`,
|
| 191 |
scale: 10,
|
| 192 |
};
|
| 193 |
}
|
|
|
|
| 279 |
continue;
|
| 280 |
}
|
| 281 |
|
| 282 |
+
// OpenArm: openarm_(left|right)_joint(\d+) → (left|right)_joint_(\d+) or (left|right)_joint(\d+),
|
| 283 |
+
// whole words so joint1 doesn't take joint10
|
| 284 |
const armMatch = lower.match(/^openarm_(left|right)_joint(\d+)$/);
|
| 285 |
if (armMatch) {
|
| 286 |
+
const pattern = new RegExp(
|
| 287 |
+
`(^|_)${armMatch[1]}_joint_?${armMatch[2]}(\\D|$)`,
|
| 288 |
+
);
|
| 289 |
+
const idx = suffixes.findIndex((s) => pattern.test(s));
|
| 290 |
if (idx >= 0) {
|
| 291 |
mapping[jointName] = columnKeys[idx];
|
| 292 |
continue;
|
|
|
|
| 359 |
// ─── Robot scene (imperative, inside Canvas) ───
|
| 360 |
function RobotScene({
|
| 361 |
urdfUrl,
|
| 362 |
+
usdUrl,
|
| 363 |
jointValues,
|
| 364 |
onJointsLoaded,
|
| 365 |
trailEnabled,
|
|
|
|
| 367 |
scale,
|
| 368 |
}: {
|
| 369 |
urdfUrl: string;
|
| 370 |
+
usdUrl?: string;
|
| 371 |
jointValues: Record<string, number>;
|
| 372 |
onJointsLoaded: (names: string[]) => void;
|
| 373 |
trailEnabled: boolean;
|
|
|
|
| 468 |
// `if (obj instanceof THREE.Mesh) obj.material = material;`
|
| 469 |
// on every mesh we hand back — overwriting our PBR material with the
|
| 470 |
// URDF's <material rgba="...">-derived MeshPhongMaterial. Wrapping the
|
| 471 |
+
// returned mesh in a Group dodges that check so our materials survive.
|
|
|
|
| 472 |
const wrapForUrdf = (obj: THREE.Object3D): THREE.Object3D => {
|
| 473 |
const group = new THREE.Group();
|
| 474 |
group.add(obj);
|
| 475 |
return group;
|
| 476 |
};
|
| 477 |
loader.loadMeshCb = (url, mgr, onLoad) => {
|
| 478 |
+
/// The USD draws these robots: their URDF only moves it.
|
| 479 |
+
if (usdUrl) {
|
| 480 |
+
onLoad(new THREE.Object3D());
|
| 481 |
+
return;
|
| 482 |
+
}
|
| 483 |
+
const makeMaterial = () => {
|
| 484 |
let color = "#9ba1ab";
|
| 485 |
let metalness = 0.1;
|
| 486 |
let roughness = 0.5;
|
|
|
|
| 487 |
if (isG1) {
|
| 488 |
const lower = url.toLowerCase();
|
| 489 |
const isWhitePart =
|
|
|
|
| 496 |
color = isWhitePart ? "#9ca3af" : "#1f2937";
|
| 497 |
metalness = 0.25;
|
| 498 |
roughness = 0.6;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 499 |
}
|
| 500 |
+
return new THREE.MeshStandardMaterial({ color, metalness, roughness });
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 501 |
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
// STL files — apply final PBR materials directly here. We used to do a
|
| 503 |
// post-load archetype rebuild in manager.onLoad, but STLLoader calls
|
| 504 |
// `manager.itemEnd` *before* our Promise resolves — so when the last
|
|
|
|
| 516 |
/// with every mesh prefetched it would otherwise never fire.
|
| 517 |
mgr.itemStart(url);
|
| 518 |
loadStlGeometry(url)
|
| 519 |
+
.then((geometry) =>
|
| 520 |
+
onLoad(wrapForUrdf(new THREE.Mesh(geometry, makeMaterial()))),
|
| 521 |
+
)
|
| 522 |
.catch((err) => onLoad(new THREE.Object3D(), err as Error))
|
| 523 |
.finally(() => mgr.itemEnd(url));
|
| 524 |
};
|
|
|
|
| 544 |
|
| 545 |
loader.workingPath = THREE.LoaderUtils.extractUrlBase(urdfUrl);
|
| 546 |
let cancelled = false;
|
| 547 |
+
let disposeUsd: (() => void) | undefined;
|
| 548 |
manager.itemStart(urdfUrl);
|
| 549 |
loadUrdfText(urdfUrl)
|
| 550 |
.then((text) => {
|
|
|
|
| 591 |
j.jointType === "prismatic",
|
| 592 |
)
|
| 593 |
.map((j) => j.name);
|
| 594 |
+
if (!usdUrl) {
|
| 595 |
+
onJointsLoaded(movable);
|
| 596 |
+
return;
|
| 597 |
+
}
|
| 598 |
+
/// Loaded from memory, so the session cache spares a download on every visit to the tab.
|
| 599 |
+
const name = usdUrl.slice(usdUrl.lastIndexOf("/") + 1);
|
| 600 |
+
return loadUsdBlob(usdUrl)
|
| 601 |
+
.then((blob) => loadUsd(name, { files: new Map([[name, blob]]) }))
|
| 602 |
+
.then(({ root, dispose }) => {
|
| 603 |
+
if (cancelled) return dispose();
|
| 604 |
+
disposeUsd = dispose;
|
| 605 |
+
attachUsdMeshes(robot, root);
|
| 606 |
+
onJointsLoaded(movable);
|
| 607 |
+
});
|
| 608 |
})
|
| 609 |
.catch((err) => {
|
| 610 |
if (cancelled) return;
|
| 611 |
+
console.error("Error loading the robot model:", err);
|
| 612 |
setError(String(err));
|
| 613 |
})
|
| 614 |
.finally(() => {
|
|
|
|
| 620 |
scene.remove(robotRef.current);
|
| 621 |
robotRef.current = null;
|
| 622 |
}
|
| 623 |
+
disposeUsd?.();
|
| 624 |
tipLinksRef.current = [];
|
| 625 |
};
|
| 626 |
+
}, [urdfUrl, usdUrl, scale, scene, onJointsLoaded, ensureTrails, invalidate]);
|
| 627 |
|
| 628 |
const tipWorldPos = useMemo(() => new THREE.Vector3(), []);
|
| 629 |
|
|
|
|
| 716 |
if (error)
|
| 717 |
return (
|
| 718 |
<Html center>
|
| 719 |
+
<span className="text-red-400">Failed to load the 3D model</span>
|
| 720 |
</Html>
|
| 721 |
);
|
| 722 |
return null;
|
|
|
|
| 826 |
() => getRobotConfig(datasetInfo.robot_type),
|
| 827 |
[datasetInfo.robot_type],
|
| 828 |
);
|
| 829 |
+
const { urdfUrl, usdUrl, scale } = robotConfig;
|
| 830 |
const isG1 = urdfUrl.includes("g1");
|
| 831 |
const isOpenArm = urdfUrl.includes("openarm");
|
| 832 |
const repoId = org && dataset ? `${org}/${dataset}` : null;
|
|
|
|
| 1127 |
</mesh>
|
| 1128 |
<RobotScene
|
| 1129 |
urdfUrl={urdfUrl}
|
| 1130 |
+
usdUrl={usdUrl}
|
| 1131 |
jointValues={jointValues}
|
| 1132 |
onJointsLoaded={onJointsLoaded}
|
| 1133 |
trailEnabled={trailEnabled}
|
src/lib/__tests__/hand-tracking.test.ts
ADDED
|
@@ -0,0 +1,317 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { describe, expect, test } from "bun:test";
|
| 2 |
+
import {
|
| 3 |
+
fluToScene,
|
| 4 |
+
cameraPathRuns,
|
| 5 |
+
handFrameIndex,
|
| 6 |
+
handProjectionMetadata,
|
| 7 |
+
hasHandTracking,
|
| 8 |
+
parseHandCalibration,
|
| 9 |
+
projectHandPoint,
|
| 10 |
+
readHandTracking,
|
| 11 |
+
wristTrail,
|
| 12 |
+
} from "../hand-tracking";
|
| 13 |
+
import type { DatasetMetadata } from "@/utils/parquetUtils";
|
| 14 |
+
|
| 15 |
+
function info(): DatasetMetadata {
|
| 16 |
+
const widths = {
|
| 17 |
+
action: 156,
|
| 18 |
+
"annotation.hand_joints_camera": 126,
|
| 19 |
+
"annotation.hand_joints_world": 126,
|
| 20 |
+
"annotation.hand_action_valid": 1,
|
| 21 |
+
"annotation.hand_observed": 2,
|
| 22 |
+
"annotation.hand_reference_frame": 1,
|
| 23 |
+
};
|
| 24 |
+
return {
|
| 25 |
+
codebase_version: "v3.0",
|
| 26 |
+
robot_type: "human_egocentric",
|
| 27 |
+
fps: 10,
|
| 28 |
+
total_episodes: 1,
|
| 29 |
+
total_frames: 6,
|
| 30 |
+
total_tasks: 1,
|
| 31 |
+
total_videos: 1,
|
| 32 |
+
total_chunks: 1,
|
| 33 |
+
chunks_size: 1000,
|
| 34 |
+
splits: {},
|
| 35 |
+
data_path: "",
|
| 36 |
+
video_path: "",
|
| 37 |
+
features: {
|
| 38 |
+
...Object.fromEntries(
|
| 39 |
+
Object.entries(widths).map(([key, width]) => [
|
| 40 |
+
key,
|
| 41 |
+
{ dtype: "float32", shape: [width], names: null },
|
| 42 |
+
]),
|
| 43 |
+
),
|
| 44 |
+
"observation.images.ego": {
|
| 45 |
+
dtype: "video",
|
| 46 |
+
shape: [480, 640, 3],
|
| 47 |
+
names: null,
|
| 48 |
+
},
|
| 49 |
+
},
|
| 50 |
+
};
|
| 51 |
+
}
|
| 52 |
+
const calibration = {
|
| 53 |
+
model: "pinhole",
|
| 54 |
+
width: 640,
|
| 55 |
+
height: 480,
|
| 56 |
+
fx: 600,
|
| 57 |
+
fy: 610,
|
| 58 |
+
cx: 320,
|
| 59 |
+
cy: 240,
|
| 60 |
+
distortion: [],
|
| 61 |
+
};
|
| 62 |
+
const metadata = {
|
| 63 |
+
cameraKey: "observation.images.ego",
|
| 64 |
+
calibration: parseHandCalibration(calibration),
|
| 65 |
+
overlayIssue: null,
|
| 66 |
+
};
|
| 67 |
+
function numericFrames(): {
|
| 68 |
+
length: number;
|
| 69 |
+
timestamps: number[];
|
| 70 |
+
series: Record<string, number[][]>;
|
| 71 |
+
} {
|
| 72 |
+
const vector = () =>
|
| 73 |
+
Array.from({ length: 126 }, (_, component) =>
|
| 74 |
+
Array.from({ length: 6 }, () =>
|
| 75 |
+
component % 3 === 2 ? 1 : component / 100,
|
| 76 |
+
),
|
| 77 |
+
);
|
| 78 |
+
return {
|
| 79 |
+
length: 6,
|
| 80 |
+
timestamps: [20, 20.1, 20.2, 20.3, 20.4, 20.5],
|
| 81 |
+
series: {
|
| 82 |
+
"annotation.hand_joints_camera": vector(),
|
| 83 |
+
"annotation.hand_joints_world": vector(),
|
| 84 |
+
"annotation.hand_action_valid": [[1, 1, 0, 1, 1, 1]],
|
| 85 |
+
"annotation.hand_observed": [
|
| 86 |
+
[1, 1, 1, 1, 1, 1],
|
| 87 |
+
[1, 1, 1, 1, 1, 1],
|
| 88 |
+
],
|
| 89 |
+
"annotation.hand_reference_frame": [[0, 0, -1, 3, 3, 5]],
|
| 90 |
+
},
|
| 91 |
+
};
|
| 92 |
+
}
|
| 93 |
+
|
| 94 |
+
describe("native hand schema and time alignment", () => {
|
| 95 |
+
test("an arbitrary 156D action is not detected as hand tracking", () => {
|
| 96 |
+
const metadata = info();
|
| 97 |
+
expect(hasHandTracking(metadata)).toBe(true);
|
| 98 |
+
delete metadata.features["annotation.hand_joints_camera"];
|
| 99 |
+
expect(hasHandTracking(metadata)).toBe(false);
|
| 100 |
+
});
|
| 101 |
+
test("all source frames survive, with local timestamps and independently masked hands", () => {
|
| 102 |
+
const frames = numericFrames();
|
| 103 |
+
frames.series["annotation.hand_observed"][1][1] = 0;
|
| 104 |
+
const data = readHandTracking(info(), frames, metadata)!;
|
| 105 |
+
expect(data.frames).toHaveLength(6);
|
| 106 |
+
expect(data.frames[1].time).toBeCloseTo(0.1);
|
| 107 |
+
expect(data.frames[0].camera[0]).toHaveLength(21);
|
| 108 |
+
expect(data.frames[1].camera[1]).toBeNull();
|
| 109 |
+
expect(data.frames[2].camera).toEqual([null, null]);
|
| 110 |
+
expect(data.frames[2].segment).toEqual([null, null]);
|
| 111 |
+
});
|
| 112 |
+
test("a non-finite point hides the affected hand in both views", () => {
|
| 113 |
+
const frames = numericFrames();
|
| 114 |
+
frames.series["annotation.hand_joints_camera"][0][0] = NaN;
|
| 115 |
+
const data = readHandTracking(info(), frames, metadata)!;
|
| 116 |
+
expect(data.frames[0].camera[0]).toBeNull();
|
| 117 |
+
expect(data.frames[0].segment[0]).toBeNull();
|
| 118 |
+
expect(data.frames[0].segment[1]).not.toBeNull();
|
| 119 |
+
});
|
| 120 |
+
test("selects the displayed frame without interpolating or looking ahead", () => {
|
| 121 |
+
const frames = readHandTracking(info(), numericFrames(), metadata)!.frames;
|
| 122 |
+
expect(handFrameIndex(frames, 0.099)).toBe(0);
|
| 123 |
+
expect(handFrameIndex(frames, 0.1)).toBe(1);
|
| 124 |
+
expect(handFrameIndex(frames, 0.2)).toBe(2);
|
| 125 |
+
// A frame at 0.2 s lives at media time 12.7 s in a shared video starting at 12.5 s.
|
| 126 |
+
expect(handFrameIndex(frames, 12.7 - 12.5)).toBe(2);
|
| 127 |
+
expect(handFrameIndex(frames, -1)).toBe(-1);
|
| 128 |
+
});
|
| 129 |
+
test("trails stop at gaps and segment-reference resets", () => {
|
| 130 |
+
const frames = readHandTracking(info(), numericFrames(), metadata)!.frames;
|
| 131 |
+
expect(wristTrail(frames, 1, 0)).toHaveLength(2);
|
| 132 |
+
expect(wristTrail(frames, 2, 0)).toHaveLength(0);
|
| 133 |
+
expect(wristTrail(frames, 4, 0)).toHaveLength(2);
|
| 134 |
+
expect(wristTrail(frames, 5, 0)).toHaveLength(1);
|
| 135 |
+
expect(fluToScene([1, 2, 3])).toEqual([-2, 3, -1]);
|
| 136 |
+
});
|
| 137 |
+
});
|
| 138 |
+
|
| 139 |
+
describe("recording-wide camera and wrist coordinates", () => {
|
| 140 |
+
const rigid = (x = 0) => [1, 0, 0, x, 0, 1, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
|
| 141 |
+
const withPoses = (poses: number[][]) => {
|
| 142 |
+
const frames = numericFrames();
|
| 143 |
+
frames.series["annotation.camera_world_from_optical"] = Array.from(
|
| 144 |
+
{ length: 16 },
|
| 145 |
+
(_, a) => poses.map((pose) => pose[a]),
|
| 146 |
+
);
|
| 147 |
+
return readHandTracking(info(), frames, metadata)!;
|
| 148 |
+
};
|
| 149 |
+
test("removes the first camera's translation and rotation from the recording frame", () => {
|
| 150 |
+
const pose = [0, -1, 0, 10, 1, 0, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
|
| 151 |
+
const moved = [...pose];
|
| 152 |
+
moved[7] += 1;
|
| 153 |
+
const tracking = withPoses([pose, moved, pose, pose, pose, pose]);
|
| 154 |
+
expect(tracking.coordinateFrame).toBe("recording");
|
| 155 |
+
expect(tracking.frames[0].cameraPose?.position).toEqual([0, -0, -0]);
|
| 156 |
+
expect(tracking.frames[1].cameraPose?.position).toEqual([0, -1, -0]);
|
| 157 |
+
expect(tracking.frames[0].recording[0]?.[0][0]).toBeCloseTo(1);
|
| 158 |
+
expect(tracking.frames[1].recording[0]?.[0][1]).toBeCloseTo(-1);
|
| 159 |
+
expect(tracking.frames[1].recording[0]?.[0][2]).toBeCloseTo(-0.01);
|
| 160 |
+
});
|
| 161 |
+
test("rotates optical joints with each camera pose instead of displaying span-local joints as global", () => {
|
| 162 |
+
const rotated = [0, -1, 0, 1, 1, 0, 0, 20, 0, 0, 1, 30, 0, 0, 0, 1];
|
| 163 |
+
const tracking = withPoses([
|
| 164 |
+
rigid(),
|
| 165 |
+
rotated,
|
| 166 |
+
rigid(),
|
| 167 |
+
rigid(),
|
| 168 |
+
rigid(),
|
| 169 |
+
rigid(),
|
| 170 |
+
]);
|
| 171 |
+
expect(tracking.frames[1].recording[0]?.[0][1]).toBeCloseTo(-0.99);
|
| 172 |
+
expect(tracking.frames[1].recording[0]?.[0][2]).toBeCloseTo(0);
|
| 173 |
+
expect(tracking.frames[1].cameraPose?.axes[0]).toEqual([0, 0, -1]);
|
| 174 |
+
// Reference frame changes at frame 5, but the saved global trajectory remains continuous.
|
| 175 |
+
expect(wristTrail(tracking.frames, 5, 0, 2, "recording")).toHaveLength(3);
|
| 176 |
+
expect(wristTrail(tracking.frames, 5, 0)).toHaveLength(1);
|
| 177 |
+
});
|
| 178 |
+
test("zero, reflected and non-rigid camera placeholders split the trajectory and hide global hands", () => {
|
| 179 |
+
const reflection = rigid();
|
| 180 |
+
reflection[0] = -1;
|
| 181 |
+
const scaled = rigid();
|
| 182 |
+
scaled[0] = 2;
|
| 183 |
+
const tracking = withPoses([
|
| 184 |
+
rigid(),
|
| 185 |
+
rigid(1),
|
| 186 |
+
Array(16).fill(0),
|
| 187 |
+
reflection,
|
| 188 |
+
scaled,
|
| 189 |
+
rigid(2),
|
| 190 |
+
]);
|
| 191 |
+
expect(cameraPathRuns(tracking.frames, 5).map((run) => run.length)).toEqual(
|
| 192 |
+
[2, 1],
|
| 193 |
+
);
|
| 194 |
+
for (const i of [2, 3, 4]) {
|
| 195 |
+
expect(tracking.frames[i].cameraPose).toBeNull();
|
| 196 |
+
expect(tracking.frames[i].recording).toEqual([null, null]);
|
| 197 |
+
}
|
| 198 |
+
expect(tracking.frames[5].recording[0]).not.toBeNull();
|
| 199 |
+
});
|
| 200 |
+
test("older native exports without saved camera transforms retain span-local 3D", () => {
|
| 201 |
+
const tracking = readHandTracking(info(), numericFrames(), metadata)!;
|
| 202 |
+
expect(tracking.coordinateFrame).toBe("segment");
|
| 203 |
+
expect(tracking.frames[0].segment[0]).not.toBeNull();
|
| 204 |
+
expect(cameraPathRuns(tracking.frames, 5)).toEqual([]);
|
| 205 |
+
});
|
| 206 |
+
});
|
| 207 |
+
|
| 208 |
+
describe("episode-specific source camera binding", () => {
|
| 209 |
+
test("an incomplete episode mapping cannot select an unindexed report entry", () => {
|
| 210 |
+
const selected = handProjectionMetadata(info(), 0, {
|
| 211 |
+
source_episode_map: [{ episode_index: 1, source_episode_index: 7 }],
|
| 212 |
+
episodes: [{ hand_actions: { camera_intrinsics: calibration } }],
|
| 213 |
+
});
|
| 214 |
+
expect(selected.calibration).toBeNull();
|
| 215 |
+
expect(selected.overlayIssue).toContain("No camera calibration");
|
| 216 |
+
});
|
| 217 |
+
test("uses the source episode map, not completion order or output index", () => {
|
| 218 |
+
const report = {
|
| 219 |
+
manifest: { camera: "observation.images.ego" },
|
| 220 |
+
source_episode_map: [{ episode_index: 0, source_episode_index: 7 }],
|
| 221 |
+
episodes: [
|
| 222 |
+
{
|
| 223 |
+
episode_index: 0,
|
| 224 |
+
hand_actions: { camera_intrinsics: { ...calibration, fx: 100 } },
|
| 225 |
+
},
|
| 226 |
+
{
|
| 227 |
+
episode_index: 7,
|
| 228 |
+
hand_actions: {
|
| 229 |
+
camera_intrinsics: { ...calibration, fx: 700, measured: false },
|
| 230 |
+
},
|
| 231 |
+
},
|
| 232 |
+
],
|
| 233 |
+
};
|
| 234 |
+
const selected = handProjectionMetadata(info(), 0, report);
|
| 235 |
+
expect(selected.calibration?.fx).toBe(700);
|
| 236 |
+
expect(selected.calibration?.estimated).toBe(true);
|
| 237 |
+
});
|
| 238 |
+
test("never projects source-camera coordinates onto another camera", () => {
|
| 239 |
+
expect(
|
| 240 |
+
handProjectionMetadata(info(), 0, {
|
| 241 |
+
manifest: { camera: "missing.camera" },
|
| 242 |
+
}).cameraKey,
|
| 243 |
+
).toBeNull();
|
| 244 |
+
const data = info();
|
| 245 |
+
data.features["observation.images.other"] =
|
| 246 |
+
data.features["observation.images.ego"];
|
| 247 |
+
expect(handProjectionMetadata(data, 0, null).cameraKey).toBeNull();
|
| 248 |
+
});
|
| 249 |
+
test("missing calibration keeps 3D available and explains why overlays are unavailable", () => {
|
| 250 |
+
const selected = handProjectionMetadata(info(), 0, null);
|
| 251 |
+
expect(selected.calibration).toBeNull();
|
| 252 |
+
expect(selected.overlayIssue).toContain("No camera calibration");
|
| 253 |
+
expect(
|
| 254 |
+
readHandTracking(info(), numericFrames(), selected)?.frames[0].segment[0],
|
| 255 |
+
).not.toBeNull();
|
| 256 |
+
});
|
| 257 |
+
test("accepts measured calibration JSON stored on the video feature", () => {
|
| 258 |
+
const data = info();
|
| 259 |
+
data.features["observation.images.ego"].info = {
|
| 260 |
+
"camera.intrinsics": JSON.stringify(calibration),
|
| 261 |
+
};
|
| 262 |
+
expect(handProjectionMetadata(data, 0, null).calibration?.fx).toBe(600);
|
| 263 |
+
expect(parseHandCalibration({ ...calibration, fx: Infinity })).toBeNull();
|
| 264 |
+
});
|
| 265 |
+
test("rejects malformed calibration at the unknown-data boundary", () => {
|
| 266 |
+
for (const value of [
|
| 267 |
+
null,
|
| 268 |
+
[],
|
| 269 |
+
"invalid JSON",
|
| 270 |
+
{ ...calibration, width: 640.5 },
|
| 271 |
+
{ ...calibration, fx: "600" },
|
| 272 |
+
{ ...calibration, distortion: [0, 0, 0, "0"] },
|
| 273 |
+
]) {
|
| 274 |
+
expect(parseHandCalibration(value)).toBeNull();
|
| 275 |
+
}
|
| 276 |
+
});
|
| 277 |
+
});
|
| 278 |
+
|
| 279 |
+
describe("source camera projection", () => {
|
| 280 |
+
// Reference outputs generated with OpenCV projectPoints, not the implementation under test.
|
| 281 |
+
const d = [
|
| 282 |
+
0.1, -0.02, 0.003, -0.004, 0.01, 0.02, -0.01, 0.005, 0.001, -0.002, 0.003,
|
| 283 |
+
-0.004, 0.02, -0.03,
|
| 284 |
+
];
|
| 285 |
+
const expected = [
|
| 286 |
+
[0, 360, 321.3333333333333],
|
| 287 |
+
[4, 360.04582716049384, 321.57562633744857],
|
| 288 |
+
[5, 360.04583155006856, 321.5756352629172],
|
| 289 |
+
[8, 360.02821795025153, 321.53982094328916],
|
| 290 |
+
[12, 360.0409586909922, 321.5792826716842],
|
| 291 |
+
[14, 360.2472660915072, 322.00366115201376],
|
| 292 |
+
];
|
| 293 |
+
for (const [count, x, y] of expected)
|
| 294 |
+
test(`pinhole ${count}-coefficient projection matches OpenCV`, () => {
|
| 295 |
+
const c = parseHandCalibration({
|
| 296 |
+
...calibration,
|
| 297 |
+
distortion: d.slice(0, count),
|
| 298 |
+
})!;
|
| 299 |
+
const p = projectHandPoint([0.1, 0.2, 1.5], c)!;
|
| 300 |
+
expect(p[0]).toBeCloseTo(x, 8);
|
| 301 |
+
expect(p[1]).toBeCloseTo(y, 8);
|
| 302 |
+
});
|
| 303 |
+
test("fisheye projection matches OpenCV", () => {
|
| 304 |
+
const c = parseHandCalibration({
|
| 305 |
+
...calibration,
|
| 306 |
+
model: "opencv_fisheye",
|
| 307 |
+
distortion: d.slice(0, 4),
|
| 308 |
+
})!;
|
| 309 |
+
const p = projectHandPoint([0.1, 0.2, 1.5], c)!;
|
| 310 |
+
expect(p[0]).toBeCloseTo(359.7941667826154, 8);
|
| 311 |
+
expect(p[1]).toBeCloseTo(320.9148057913179, 8);
|
| 312 |
+
});
|
| 313 |
+
test("points behind the image plane never appear", () => {
|
| 314 |
+
expect(projectHandPoint([0, 0, -1], metadata.calibration!)).toBeNull();
|
| 315 |
+
expect(projectHandPoint([0, 0, 0], metadata.calibration!)).toBeNull();
|
| 316 |
+
});
|
| 317 |
+
});
|
src/lib/hand-tracking.ts
ADDED
|
@@ -0,0 +1,447 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import type { DatasetMetadata } from "@/utils/parquetUtils";
|
| 2 |
+
|
| 3 |
+
export type Point3 = [number, number, number];
|
| 4 |
+
export type HandIndex = 0 | 1;
|
| 5 |
+
export type HandPair = [Point3[] | null, Point3[] | null];
|
| 6 |
+
export type CameraPose = { position: Point3; axes: [Point3, Point3, Point3] };
|
| 7 |
+
export type HandFrame = {
|
| 8 |
+
time: number;
|
| 9 |
+
referenceFrame: number;
|
| 10 |
+
camera: HandPair;
|
| 11 |
+
segment: HandPair;
|
| 12 |
+
recording: HandPair;
|
| 13 |
+
cameraPose: CameraPose | null;
|
| 14 |
+
};
|
| 15 |
+
export type HandCalibration = {
|
| 16 |
+
model: "pinhole" | "opencv_fisheye";
|
| 17 |
+
width: number;
|
| 18 |
+
height: number;
|
| 19 |
+
fx: number;
|
| 20 |
+
fy: number;
|
| 21 |
+
cx: number;
|
| 22 |
+
cy: number;
|
| 23 |
+
distortion: number[];
|
| 24 |
+
estimated: boolean;
|
| 25 |
+
};
|
| 26 |
+
export type HandTracking = {
|
| 27 |
+
frames: HandFrame[];
|
| 28 |
+
fps: number;
|
| 29 |
+
cameraKey: string | null;
|
| 30 |
+
calibration: HandCalibration | null;
|
| 31 |
+
overlayIssue: string | null;
|
| 32 |
+
coordinateFrame: "recording" | "segment";
|
| 33 |
+
};
|
| 34 |
+
type NumericFrames = {
|
| 35 |
+
length: number;
|
| 36 |
+
timestamps: number[];
|
| 37 |
+
series: Record<string, number[][]>;
|
| 38 |
+
};
|
| 39 |
+
|
| 40 |
+
export const HAND_COLORS = ["#59b5ff", "#ffb454"] as const;
|
| 41 |
+
export const HAND_INDICES = [0, 1] as const;
|
| 42 |
+
export const HAND_EDGES = [1, 5, 9, 13, 17].flatMap(
|
| 43 |
+
(base): [number, number][] => [
|
| 44 |
+
[0, base],
|
| 45 |
+
[base, base + 1],
|
| 46 |
+
[base + 1, base + 2],
|
| 47 |
+
[base + 2, base + 3],
|
| 48 |
+
],
|
| 49 |
+
);
|
| 50 |
+
const HAND_WIDTHS: Record<string, number> = {
|
| 51 |
+
"annotation.hand_joints_camera": 126,
|
| 52 |
+
"annotation.hand_joints_world": 126,
|
| 53 |
+
"annotation.hand_action_valid": 1,
|
| 54 |
+
"annotation.hand_observed": 2,
|
| 55 |
+
"annotation.hand_reference_frame": 1,
|
| 56 |
+
};
|
| 57 |
+
|
| 58 |
+
/** The SOMA156 contract, rather than a robot name or an arbitrary 156D action. */
|
| 59 |
+
export function hasHandTracking(info: DatasetMetadata): boolean {
|
| 60 |
+
return (
|
| 61 |
+
info.features.action?.shape.join() === "156" &&
|
| 62 |
+
Object.entries(HAND_WIDTHS).every(
|
| 63 |
+
([key, width]) => info.features[key]?.shape.join() === String(width),
|
| 64 |
+
)
|
| 65 |
+
);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
/** Native auxiliary arrays have their own viewer, rather than hundreds of chart series. */
|
| 69 |
+
export function isHandGeometryColumn(key: string): boolean {
|
| 70 |
+
return (
|
| 71 |
+
key.startsWith("annotation.hand_") ||
|
| 72 |
+
key.startsWith("annotation.soma_") ||
|
| 73 |
+
key === "annotation.wrist_rotation_camera" ||
|
| 74 |
+
key === "annotation.camera_world_from_optical"
|
| 75 |
+
);
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
function record(value: unknown): Record<string, unknown> {
|
| 79 |
+
return value !== null && typeof value === "object" && !Array.isArray(value)
|
| 80 |
+
? (value as Record<string, unknown>)
|
| 81 |
+
: {};
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
function isFiniteNumber(value: unknown): value is number {
|
| 85 |
+
return typeof value === "number" && Number.isFinite(value);
|
| 86 |
+
}
|
| 87 |
+
|
| 88 |
+
export function parseHandCalibration(value: unknown): HandCalibration | null {
|
| 89 |
+
if (typeof value === "string") {
|
| 90 |
+
try {
|
| 91 |
+
value = JSON.parse(value);
|
| 92 |
+
} catch {
|
| 93 |
+
return null;
|
| 94 |
+
}
|
| 95 |
+
}
|
| 96 |
+
const c = record(value);
|
| 97 |
+
const { width, height, fx, fy, cx, cy } = c;
|
| 98 |
+
if (
|
| 99 |
+
!isFiniteNumber(width) ||
|
| 100 |
+
!Number.isInteger(width) ||
|
| 101 |
+
width <= 0 ||
|
| 102 |
+
!isFiniteNumber(height) ||
|
| 103 |
+
!Number.isInteger(height) ||
|
| 104 |
+
height <= 0 ||
|
| 105 |
+
!isFiniteNumber(fx) ||
|
| 106 |
+
fx <= 0 ||
|
| 107 |
+
!isFiniteNumber(fy) ||
|
| 108 |
+
fy <= 0 ||
|
| 109 |
+
!isFiniteNumber(cx) ||
|
| 110 |
+
!isFiniteNumber(cy)
|
| 111 |
+
)
|
| 112 |
+
return null;
|
| 113 |
+
if (c.model !== "pinhole" && c.model !== "opencv_fisheye") return null;
|
| 114 |
+
const d = c.distortion ?? [];
|
| 115 |
+
if (
|
| 116 |
+
!Array.isArray(d) ||
|
| 117 |
+
!d.every(isFiniteNumber) ||
|
| 118 |
+
!(c.model === "pinhole" ? [0, 4, 5, 8, 12, 14] : [4]).includes(d.length)
|
| 119 |
+
)
|
| 120 |
+
return null;
|
| 121 |
+
return {
|
| 122 |
+
model: c.model,
|
| 123 |
+
width,
|
| 124 |
+
height,
|
| 125 |
+
fx,
|
| 126 |
+
fy,
|
| 127 |
+
cx,
|
| 128 |
+
cy,
|
| 129 |
+
distortion: d,
|
| 130 |
+
estimated: c.measured === false || c.source === "moge-estimated",
|
| 131 |
+
};
|
| 132 |
+
}
|
| 133 |
+
|
| 134 |
+
/** Run episodes are source IDs and can finish out of order; exports can be reindexed. */
|
| 135 |
+
export function handProjectionMetadata(
|
| 136 |
+
info: DatasetMetadata,
|
| 137 |
+
episodeId: number,
|
| 138 |
+
reportValue: unknown,
|
| 139 |
+
): Pick<HandTracking, "cameraKey" | "calibration" | "overlayIssue"> {
|
| 140 |
+
const report = record(reportValue);
|
| 141 |
+
const manifest = record(report.manifest);
|
| 142 |
+
const cameras = Object.keys(info.features).filter(
|
| 143 |
+
(key) => info.features[key].dtype === "video",
|
| 144 |
+
);
|
| 145 |
+
const cameraKey =
|
| 146 |
+
typeof manifest.camera === "string"
|
| 147 |
+
? cameras.includes(manifest.camera)
|
| 148 |
+
? manifest.camera
|
| 149 |
+
: null
|
| 150 |
+
: cameras.length === 1
|
| 151 |
+
? cameras[0]
|
| 152 |
+
: null;
|
| 153 |
+
const mapping = Array.isArray(report.source_episode_map)
|
| 154 |
+
? report.source_episode_map.map(record)
|
| 155 |
+
: null;
|
| 156 |
+
const mapped = mapping?.find((entry) => entry.episode_index === episodeId);
|
| 157 |
+
const sourceId = mapping ? mapped?.source_episode_index : episodeId;
|
| 158 |
+
const episodes = Array.isArray(report.episodes)
|
| 159 |
+
? report.episodes.map(record)
|
| 160 |
+
: [];
|
| 161 |
+
const sourceEpisode =
|
| 162 |
+
isFiniteNumber(sourceId) && Number.isSafeInteger(sourceId) && sourceId >= 0
|
| 163 |
+
? episodes.find((entry) => entry.episode_index === sourceId)
|
| 164 |
+
: undefined;
|
| 165 |
+
const handResult = record(sourceEpisode?.hand_actions);
|
| 166 |
+
const calibration =
|
| 167 |
+
parseHandCalibration(handResult.camera_intrinsics) ??
|
| 168 |
+
parseHandCalibration(
|
| 169 |
+
cameraKey ? info.features[cameraKey].info?.["camera.intrinsics"] : null,
|
| 170 |
+
);
|
| 171 |
+
return {
|
| 172 |
+
cameraKey,
|
| 173 |
+
calibration,
|
| 174 |
+
overlayIssue: !cameraKey
|
| 175 |
+
? "The annotation camera is unknown; camera overlays are unavailable."
|
| 176 |
+
: !calibration
|
| 177 |
+
? "No camera calibration is saved for this episode; 3D hands remain available."
|
| 178 |
+
: null,
|
| 179 |
+
};
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
/** Keep the full source frame grid. Never turn masked zero placeholders into hands. */
|
| 183 |
+
export function readHandTracking(
|
| 184 |
+
info: DatasetMetadata,
|
| 185 |
+
frames: NumericFrames | undefined,
|
| 186 |
+
metadata: ReturnType<typeof handProjectionMetadata>,
|
| 187 |
+
): HandTracking | undefined {
|
| 188 |
+
if (!hasHandTracking(info)) return undefined;
|
| 189 |
+
const rows: HandFrame[] = [];
|
| 190 |
+
const cameraMatrices: (number[] | null)[] = [];
|
| 191 |
+
const timeOrigin = frames?.timestamps[0] ?? 0;
|
| 192 |
+
const points = (
|
| 193 |
+
key: string,
|
| 194 |
+
frame: number,
|
| 195 |
+
hand: HandIndex,
|
| 196 |
+
): Point3[] | null => {
|
| 197 |
+
const components = frames?.series[key];
|
| 198 |
+
if (components?.length !== 126) return null;
|
| 199 |
+
const joints = Array.from({ length: 21 }, (_, joint): Point3 => {
|
| 200 |
+
const offset = hand * 63 + joint * 3;
|
| 201 |
+
return [
|
| 202 |
+
components[offset]?.[frame],
|
| 203 |
+
components[offset + 1]?.[frame],
|
| 204 |
+
components[offset + 2]?.[frame],
|
| 205 |
+
];
|
| 206 |
+
});
|
| 207 |
+
return joints.every((p) => p.every(Number.isFinite)) ? joints : null;
|
| 208 |
+
};
|
| 209 |
+
if (
|
| 210 |
+
frames &&
|
| 211 |
+
frames.timestamps.length === frames.length &&
|
| 212 |
+
Number.isFinite(timeOrigin)
|
| 213 |
+
) {
|
| 214 |
+
for (let i = 0; i < frames.length; i++) {
|
| 215 |
+
const time = frames.timestamps[i] - timeOrigin;
|
| 216 |
+
if (!Number.isFinite(time) || (i > 0 && time <= rows[i - 1].time))
|
| 217 |
+
return undefined;
|
| 218 |
+
const reference =
|
| 219 |
+
frames.series["annotation.hand_reference_frame"]?.[0]?.[i];
|
| 220 |
+
const valid =
|
| 221 |
+
frames.series["annotation.hand_action_valid"]?.[0]?.[i] === 1 &&
|
| 222 |
+
Number.isInteger(reference) &&
|
| 223 |
+
reference >= 0 &&
|
| 224 |
+
reference <= i;
|
| 225 |
+
const observed = (hand: HandIndex) =>
|
| 226 |
+
valid && frames.series["annotation.hand_observed"]?.[hand]?.[i] === 1;
|
| 227 |
+
const camera: HandPair = [null, null],
|
| 228 |
+
segment: HandPair = [null, null];
|
| 229 |
+
for (const hand of HAND_INDICES) {
|
| 230 |
+
if (!observed(hand)) continue;
|
| 231 |
+
const cameraPoints = points("annotation.hand_joints_camera", i, hand);
|
| 232 |
+
const segmentPoints = points("annotation.hand_joints_world", i, hand);
|
| 233 |
+
if (cameraPoints && segmentPoints) {
|
| 234 |
+
camera[hand] = cameraPoints;
|
| 235 |
+
segment[hand] = segmentPoints;
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
rows.push({
|
| 239 |
+
time,
|
| 240 |
+
referenceFrame: valid ? reference : -1,
|
| 241 |
+
camera,
|
| 242 |
+
segment,
|
| 243 |
+
recording: [null, null],
|
| 244 |
+
cameraPose: null,
|
| 245 |
+
});
|
| 246 |
+
const matrix = frames.series["annotation.camera_world_from_optical"];
|
| 247 |
+
const pose =
|
| 248 |
+
matrix?.length === 16 ? matrix.map((component) => component[i]) : null;
|
| 249 |
+
cameraMatrices.push(pose && isCameraTransform(pose) ? pose : null);
|
| 250 |
+
}
|
| 251 |
+
}
|
| 252 |
+
const origin = cameraMatrices.find((matrix) => matrix !== null);
|
| 253 |
+
if (origin)
|
| 254 |
+
rows.forEach((frame, i) => {
|
| 255 |
+
const pose = cameraMatrices[i];
|
| 256 |
+
if (!pose) return;
|
| 257 |
+
// Only saved rigid transforms are used. Masked zero matrices are not camera poses.
|
| 258 |
+
const relative = (p: Point3): Point3 => {
|
| 259 |
+
const world = [0, 1, 2].map(
|
| 260 |
+
(a) =>
|
| 261 |
+
pose[a * 4] * p[0] +
|
| 262 |
+
pose[a * 4 + 1] * p[1] +
|
| 263 |
+
pose[a * 4 + 2] * p[2] +
|
| 264 |
+
pose[a * 4 + 3] -
|
| 265 |
+
origin[a * 4 + 3],
|
| 266 |
+
);
|
| 267 |
+
const optical = [0, 1, 2].map(
|
| 268 |
+
(a) =>
|
| 269 |
+
origin[a] * world[0] +
|
| 270 |
+
origin[a + 4] * world[1] +
|
| 271 |
+
origin[a + 8] * world[2],
|
| 272 |
+
);
|
| 273 |
+
return [optical[2], -optical[0], -optical[1]];
|
| 274 |
+
};
|
| 275 |
+
const position = relative([0, 0, 0]);
|
| 276 |
+
const axis = (p: Point3): Point3 => {
|
| 277 |
+
const end = relative(p);
|
| 278 |
+
return [
|
| 279 |
+
end[0] - position[0],
|
| 280 |
+
end[1] - position[1],
|
| 281 |
+
end[2] - position[2],
|
| 282 |
+
];
|
| 283 |
+
};
|
| 284 |
+
frame.cameraPose = {
|
| 285 |
+
position,
|
| 286 |
+
axes: [axis([1, 0, 0]), axis([0, 1, 0]), axis([0, 0, 1])],
|
| 287 |
+
};
|
| 288 |
+
frame.recording = [
|
| 289 |
+
frame.camera[0]?.map(relative) ?? null,
|
| 290 |
+
frame.camera[1]?.map(relative) ?? null,
|
| 291 |
+
];
|
| 292 |
+
});
|
| 293 |
+
return {
|
| 294 |
+
frames: rows,
|
| 295 |
+
fps: info.fps,
|
| 296 |
+
coordinateFrame: origin ? "recording" : "segment",
|
| 297 |
+
...metadata,
|
| 298 |
+
};
|
| 299 |
+
}
|
| 300 |
+
|
| 301 |
+
function isCameraTransform(m: number[]): boolean {
|
| 302 |
+
if (
|
| 303 |
+
!m.every(Number.isFinite) ||
|
| 304 |
+
[m[12], m[13], m[14], m[15] - 1].some((n) => Math.abs(n) > 1e-4)
|
| 305 |
+
)
|
| 306 |
+
return false;
|
| 307 |
+
for (let a = 0; a < 3; a++)
|
| 308 |
+
for (let b = 0; b < 3; b++) {
|
| 309 |
+
const dot = [0, 1, 2].reduce(
|
| 310 |
+
(sum, k) => sum + m[k * 4 + a] * m[k * 4 + b],
|
| 311 |
+
0,
|
| 312 |
+
);
|
| 313 |
+
if (Math.abs(dot - Number(a === b)) > 2e-3) return false;
|
| 314 |
+
}
|
| 315 |
+
const determinant =
|
| 316 |
+
m[0] * (m[5] * m[10] - m[6] * m[9]) -
|
| 317 |
+
m[1] * (m[4] * m[10] - m[6] * m[8]) +
|
| 318 |
+
m[2] * (m[4] * m[9] - m[5] * m[8]);
|
| 319 |
+
return Math.abs(determinant - 1) < 2e-3;
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
/** The frame currently on screen, without interpolation or looking ahead to the next pose. */
|
| 323 |
+
export function handFrameIndex(frames: HandFrame[], time: number): number {
|
| 324 |
+
let lo = 0,
|
| 325 |
+
hi = frames.length;
|
| 326 |
+
while (lo < hi) {
|
| 327 |
+
const mid = (lo + hi) >>> 1;
|
| 328 |
+
if (frames[mid].time <= time + 1e-7) lo = mid + 1;
|
| 329 |
+
else hi = mid;
|
| 330 |
+
}
|
| 331 |
+
return lo - 1;
|
| 332 |
+
}
|
| 333 |
+
|
| 334 |
+
/** The stored "world" joints are segment-start FLU, not a continuous global trajectory. */
|
| 335 |
+
export function fluToScene([forward, left, up]: Point3): Point3 {
|
| 336 |
+
return [-left, up, -forward];
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
/** Break trails both at validity gaps and at each segment-reference reset. */
|
| 340 |
+
export function wristTrail(
|
| 341 |
+
frames: HandFrame[],
|
| 342 |
+
index: number,
|
| 343 |
+
hand: HandIndex,
|
| 344 |
+
seconds = 2,
|
| 345 |
+
space: "segment" | "recording" = "segment",
|
| 346 |
+
): Point3[] {
|
| 347 |
+
const trail: Point3[] = [];
|
| 348 |
+
const current = frames[index];
|
| 349 |
+
if (!current?.[space][hand]) return trail;
|
| 350 |
+
for (let i = index; i >= 0; i--) {
|
| 351 |
+
const f = frames[i];
|
| 352 |
+
const joints = f[space][hand];
|
| 353 |
+
if (
|
| 354 |
+
!joints ||
|
| 355 |
+
(space === "segment" && f.referenceFrame !== current.referenceFrame) ||
|
| 356 |
+
current.time - f.time > seconds
|
| 357 |
+
)
|
| 358 |
+
break;
|
| 359 |
+
trail.push(fluToScene(joints[0]));
|
| 360 |
+
}
|
| 361 |
+
return trail.reverse();
|
| 362 |
+
}
|
| 363 |
+
|
| 364 |
+
/** Separate camera-path runs wherever no transform was saved; never draw through zero placeholders. */
|
| 365 |
+
export function cameraPathRuns(frames: HandFrame[], index: number): Point3[][] {
|
| 366 |
+
const runs: Point3[][] = [];
|
| 367 |
+
let run: Point3[] = [];
|
| 368 |
+
for (let i = 0; i <= index; i++) {
|
| 369 |
+
const pose = frames[i]?.cameraPose;
|
| 370 |
+
if (pose) run.push(fluToScene(pose.position));
|
| 371 |
+
else if (run.length) {
|
| 372 |
+
runs.push(run);
|
| 373 |
+
run = [];
|
| 374 |
+
}
|
| 375 |
+
}
|
| 376 |
+
if (run.length) runs.push(run);
|
| 377 |
+
return runs;
|
| 378 |
+
}
|
| 379 |
+
|
| 380 |
+
/** Project optical-camera XYZ onto the original, possibly distorted source image. */
|
| 381 |
+
export function projectHandPoint(
|
| 382 |
+
point: Point3,
|
| 383 |
+
c: HandCalibration,
|
| 384 |
+
): [number, number] | null {
|
| 385 |
+
if (!point.every(Number.isFinite) || point[2] <= 1e-6) return null;
|
| 386 |
+
let x = point[0] / point[2],
|
| 387 |
+
y = point[1] / point[2];
|
| 388 |
+
const d = c.distortion;
|
| 389 |
+
const r2 = x * x + y * y;
|
| 390 |
+
if (c.model === "opencv_fisheye") {
|
| 391 |
+
const r = Math.sqrt(r2),
|
| 392 |
+
theta = Math.atan(r),
|
| 393 |
+
t2 = theta * theta;
|
| 394 |
+
const scale =
|
| 395 |
+
r < 1e-10
|
| 396 |
+
? 1
|
| 397 |
+
: (theta * (1 + t2 * (d[0] + t2 * (d[1] + t2 * (d[2] + t2 * d[3]))))) /
|
| 398 |
+
r;
|
| 399 |
+
x *= scale;
|
| 400 |
+
y *= scale;
|
| 401 |
+
} else if (d.length) {
|
| 402 |
+
const [
|
| 403 |
+
k1 = 0,
|
| 404 |
+
k2 = 0,
|
| 405 |
+
p1 = 0,
|
| 406 |
+
p2 = 0,
|
| 407 |
+
k3 = 0,
|
| 408 |
+
k4 = 0,
|
| 409 |
+
k5 = 0,
|
| 410 |
+
k6 = 0,
|
| 411 |
+
s1 = 0,
|
| 412 |
+
s2 = 0,
|
| 413 |
+
s3 = 0,
|
| 414 |
+
s4 = 0,
|
| 415 |
+
tx = 0,
|
| 416 |
+
ty = 0,
|
| 417 |
+
] = d;
|
| 418 |
+
const denominator = 1 + r2 * (k4 + r2 * (k5 + r2 * k6));
|
| 419 |
+
if (Math.abs(denominator) < 1e-12) return null;
|
| 420 |
+
const radial = (1 + r2 * (k1 + r2 * (k2 + r2 * k3))) / denominator;
|
| 421 |
+
const dx =
|
| 422 |
+
x * radial +
|
| 423 |
+
2 * p1 * x * y +
|
| 424 |
+
p2 * (r2 + 2 * x * x) +
|
| 425 |
+
s1 * r2 +
|
| 426 |
+
s2 * r2 * r2;
|
| 427 |
+
const dy =
|
| 428 |
+
y * radial +
|
| 429 |
+
p1 * (r2 + 2 * y * y) +
|
| 430 |
+
2 * p2 * x * y +
|
| 431 |
+
s3 * r2 +
|
| 432 |
+
s4 * r2 * r2;
|
| 433 |
+
// OpenCV's tilted-sensor projection (tau_x, tau_y), after radial/tangential distortion.
|
| 434 |
+
const sx = Math.sin(tx),
|
| 435 |
+
cx = Math.cos(tx),
|
| 436 |
+
sy = Math.sin(ty),
|
| 437 |
+
cy = Math.cos(ty);
|
| 438 |
+
const vx = cy * dx + sy * sx * dy - sy * cx;
|
| 439 |
+
const vy = cx * dy + sx;
|
| 440 |
+
const vz = sy * dx - cy * sx * dy + cy * cx;
|
| 441 |
+
if (Math.abs(vz) < 1e-12) return null;
|
| 442 |
+
x = (cy * cx * vx + sy * cx * vz) / vz;
|
| 443 |
+
y = (cy * cx * vy - sx * vz) / vz;
|
| 444 |
+
}
|
| 445 |
+
const pixel: [number, number] = [c.fx * x + c.cx, c.fy * y + c.cy];
|
| 446 |
+
return pixel.every(Number.isFinite) ? pixel : null;
|
| 447 |
+
}
|
src/utils/__tests__/parquetUtils.test.ts
CHANGED
|
@@ -3,7 +3,9 @@ import {
|
|
| 3 |
formatStringWithVars,
|
| 4 |
arrayToCSV,
|
| 5 |
getRows,
|
|
|
|
| 6 |
} from "@/utils/parquetUtils";
|
|
|
|
| 7 |
import { PADDING } from "@/utils/constants";
|
| 8 |
|
| 9 |
// ---------------------------------------------------------------------------
|
|
@@ -145,3 +147,47 @@ describe("getRows", () => {
|
|
| 145 |
expect(rows[0]).toEqual([0.75]);
|
| 146 |
});
|
| 147 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
formatStringWithVars,
|
| 4 |
arrayToCSV,
|
| 5 |
getRows,
|
| 6 |
+
nestedColumnValues,
|
| 7 |
} from "@/utils/parquetUtils";
|
| 8 |
+
import type { FileMetaData } from "hyparquet";
|
| 9 |
import { PADDING } from "@/utils/constants";
|
| 10 |
|
| 11 |
// ---------------------------------------------------------------------------
|
|
|
|
| 147 |
expect(rows[0]).toEqual([0.75]);
|
| 148 |
});
|
| 149 |
});
|
| 150 |
+
|
| 151 |
+
// ---------------------------------------------------------------------------
|
| 152 |
+
// nestedColumnValues — what decoding a nested column costs for a row range
|
| 153 |
+
// ---------------------------------------------------------------------------
|
| 154 |
+
describe("nestedColumnValues", () => {
|
| 155 |
+
const chunk = (path: string[], values: number) => ({
|
| 156 |
+
meta_data: { path_in_schema: path, num_values: BigInt(values) },
|
| 157 |
+
});
|
| 158 |
+
// Two row groups of 100 rows: a flat column, a list column and a list of
|
| 159 |
+
// structs whose leaves are counted together.
|
| 160 |
+
const metadata = {
|
| 161 |
+
row_groups: [0, 1].map((group) => ({
|
| 162 |
+
num_rows: 100n,
|
| 163 |
+
columns: [
|
| 164 |
+
chunk(["timestamp"], 100),
|
| 165 |
+
chunk(["language_events", "list", "element"], 10 + group),
|
| 166 |
+
chunk(["language_persistent", "list", "element", "role"], 1000),
|
| 167 |
+
chunk(["language_persistent", "list", "element", "content"], 1000),
|
| 168 |
+
],
|
| 169 |
+
})),
|
| 170 |
+
} as unknown as FileMetaData;
|
| 171 |
+
|
| 172 |
+
test("counts the whole row group, however few rows are read", () => {
|
| 173 |
+
expect(nestedColumnValues(metadata, 10, 20)).toEqual(
|
| 174 |
+
new Map([
|
| 175 |
+
["language_events", 10],
|
| 176 |
+
["language_persistent", 2000],
|
| 177 |
+
]),
|
| 178 |
+
);
|
| 179 |
+
});
|
| 180 |
+
|
| 181 |
+
test("sums every row group the range overlaps", () => {
|
| 182 |
+
expect(nestedColumnValues(metadata, 90, 110).get("language_events")).toBe(
|
| 183 |
+
21,
|
| 184 |
+
);
|
| 185 |
+
expect(nestedColumnValues(metadata, 100, 200).get("language_events")).toBe(
|
| 186 |
+
11,
|
| 187 |
+
);
|
| 188 |
+
});
|
| 189 |
+
|
| 190 |
+
test("leaves flat columns out", () => {
|
| 191 |
+
expect(nestedColumnValues(metadata, 0, 200).has("timestamp")).toBe(false);
|
| 192 |
+
});
|
| 193 |
+
});
|
src/utils/parquetUtils.ts
CHANGED
|
@@ -4,7 +4,9 @@ import {
|
|
| 4 |
parquetRead,
|
| 5 |
parquetReadObjects,
|
| 6 |
type AsyncBuffer,
|
|
|
|
| 7 |
} from "hyparquet";
|
|
|
|
| 8 |
import { authHeaders } from "./auth";
|
| 9 |
|
| 10 |
export interface DatasetMetadata {
|
|
@@ -43,6 +45,10 @@ type ParquetFile = ArrayBuffer | AsyncBuffer;
|
|
| 43 |
|
| 44 |
const parquetFileCache = new Map<string, AsyncBuffer>();
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
export async function fetchParquetFile(url: string): Promise<ParquetFile> {
|
| 47 |
const cached = parquetFileCache.get(url);
|
| 48 |
if (cached) return cached;
|
|
@@ -69,6 +75,7 @@ export async function readParquetColumn(
|
|
| 69 |
columns: columns.length > 0 ? columns : undefined,
|
| 70 |
rowStart: options?.rowStart,
|
| 71 |
rowEnd: options?.rowEnd,
|
|
|
|
| 72 |
onComplete: (data: unknown[][]) => {
|
| 73 |
resolve(data);
|
| 74 |
},
|
|
@@ -89,9 +96,38 @@ export async function readParquetAsObjects(
|
|
| 89 |
columns: columns.length > 0 ? columns : undefined,
|
| 90 |
rowStart: options?.rowStart,
|
| 91 |
rowEnd: options?.rowEnd,
|
|
|
|
| 92 |
}) as Promise<Record<string, unknown>[]>;
|
| 93 |
}
|
| 94 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 95 |
// Convert a 2D array to a CSV string
|
| 96 |
export function arrayToCSV(data: (number | string)[][]): string {
|
| 97 |
return data.map((row) => row.join(",")).join("\n");
|
|
|
|
| 4 |
parquetRead,
|
| 5 |
parquetReadObjects,
|
| 6 |
type AsyncBuffer,
|
| 7 |
+
type FileMetaData,
|
| 8 |
} from "hyparquet";
|
| 9 |
+
import { decompress } from "fzstd";
|
| 10 |
import { authHeaders } from "./auth";
|
| 11 |
|
| 12 |
export interface DatasetMetadata {
|
|
|
|
| 45 |
|
| 46 |
const parquetFileCache = new Map<string, AsyncBuffer>();
|
| 47 |
|
| 48 |
+
// hyparquet only decodes SNAPPY. No output buffer: fzstd throws on a page of
|
| 49 |
+
// several zstd frames.
|
| 50 |
+
const compressors = { ZSTD: (input: Uint8Array) => decompress(input) };
|
| 51 |
+
|
| 52 |
export async function fetchParquetFile(url: string): Promise<ParquetFile> {
|
| 53 |
const cached = parquetFileCache.get(url);
|
| 54 |
if (cached) return cached;
|
|
|
|
| 75 |
columns: columns.length > 0 ? columns : undefined,
|
| 76 |
rowStart: options?.rowStart,
|
| 77 |
rowEnd: options?.rowEnd,
|
| 78 |
+
compressors,
|
| 79 |
onComplete: (data: unknown[][]) => {
|
| 80 |
resolve(data);
|
| 81 |
},
|
|
|
|
| 96 |
columns: columns.length > 0 ? columns : undefined,
|
| 97 |
rowStart: options?.rowStart,
|
| 98 |
rowEnd: options?.rowEnd,
|
| 99 |
+
compressors,
|
| 100 |
}) as Promise<Record<string, unknown>[]>;
|
| 101 |
}
|
| 102 |
|
| 103 |
+
/**
|
| 104 |
+
* Leaf values per nested column in the row groups overlapping
|
| 105 |
+
* [rowStart, rowEnd). hyparquet decodes a nested column across its whole row
|
| 106 |
+
* group, so this is what reading it costs, however few rows are asked for.
|
| 107 |
+
*/
|
| 108 |
+
export function nestedColumnValues(
|
| 109 |
+
metadata: FileMetaData,
|
| 110 |
+
rowStart: number,
|
| 111 |
+
rowEnd: number,
|
| 112 |
+
): Map<string, number> {
|
| 113 |
+
const values = new Map<string, number>();
|
| 114 |
+
let groupEnd = 0;
|
| 115 |
+
for (const group of metadata.row_groups) {
|
| 116 |
+
const groupStart = groupEnd;
|
| 117 |
+
groupEnd += Number(group.num_rows);
|
| 118 |
+
if (groupStart >= rowEnd || groupEnd <= rowStart) continue;
|
| 119 |
+
for (const { meta_data } of group.columns) {
|
| 120 |
+
if (!meta_data || meta_data.path_in_schema.length === 1) continue;
|
| 121 |
+
const [column] = meta_data.path_in_schema;
|
| 122 |
+
values.set(
|
| 123 |
+
column,
|
| 124 |
+
(values.get(column) ?? 0) + Number(meta_data.num_values),
|
| 125 |
+
);
|
| 126 |
+
}
|
| 127 |
+
}
|
| 128 |
+
return values;
|
| 129 |
+
}
|
| 130 |
+
|
| 131 |
// Convert a 2D array to a CSV string
|
| 132 |
export function arrayToCSV(data: (number | string)[][]): string {
|
| 133 |
return data.map((row) => row.join(",")).join("\n");
|
tsconfig.test.json
CHANGED
|
@@ -6,5 +6,6 @@
|
|
| 6 |
"types": ["bun-types"],
|
| 7 |
"incremental": false
|
| 8 |
},
|
| 9 |
-
"include": ["**/__tests__/**/*.ts", "src/**/*.ts"]
|
|
|
|
| 10 |
}
|
|
|
|
| 6 |
"types": ["bun-types"],
|
| 7 |
"incremental": false
|
| 8 |
},
|
| 9 |
+
"include": ["**/__tests__/**/*.ts", "**/__tests__/**/*.tsx", "src/**/*.ts"],
|
| 10 |
+
"exclude": ["node_modules"]
|
| 11 |
}
|