mishig HF Staff commited on
Commit
ee4674d
·
verified ·
1 Parent(s): 72b248a

Sync from huggingface/lerobot-dataset-visualizer via hub-sync

Browse files
src/app/[org]/[dataset]/[episode]/fetch-data.ts CHANGED
@@ -22,7 +22,13 @@ import {
22
  depthEncodingFromFeature,
23
  } from "@/utils/colormaps";
24
  import type { VideoInfo } from "@/types";
25
- import { LeRobotDataset, parseInfo } from "@huggingface/lerobot";
 
 
 
 
 
 
26
  import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
27
  import { authHeaders, getAuthToken } from "@/utils/auth";
28
 
@@ -590,6 +596,109 @@ export async function getEpisodeData(
590
  }
591
  }
592
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
593
  // Legacy v2.x data loading
594
  async function getEpisodeDataV2(
595
  repoId: string,
@@ -620,7 +729,19 @@ async function getEpisodeDataV2(
620
  .map((x) => parseInt(x.trim(), 10))
621
  .filter((x) => !isNaN(x));
622
 
623
- const leRobotEpisode = await loadLeRobotEpisode(repoId, episodeId);
 
 
 
 
 
 
 
 
 
 
 
 
624
  const videosInfo =
625
  info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
626
 
 
22
  depthEncodingFromFeature,
23
  } from "@/utils/colormaps";
24
  import type { VideoInfo } from "@/types";
25
+ import {
26
+ LeRobotDataset,
27
+ HttpError,
28
+ fetchRange,
29
+ formatPathTemplate,
30
+ parseInfo,
31
+ } from "@huggingface/lerobot";
32
  import type { LeRobotEpisode, LeRobotFrames } from "@huggingface/lerobot";
33
  import { authHeaders, getAuthToken } from "@/utils/auth";
34
 
 
596
  }
597
  }
598
 
599
+ const v2ContiguousIndexes = new Map<string, Promise<boolean>>();
600
+ /// The package's first 8 KB read of episodes.jsonl already holds the first few dozen episodes, so
601
+ /// probing for those would only add a round trip.
602
+ const V2_PROBE_FROM_EPISODE = 32;
603
+
604
+ /**
605
+ * Whether line N of `meta/episodes.jsonl` is episode N, judged from its first and last lines.
606
+ *
607
+ * The package finds line N by re-downloading ever longer prefixes of the file (8 KB × 4^k) one
608
+ * after another and gives up past 4 MB, so the upper episodes of a large v2 dataset could not open.
609
+ * An index running 0..total-1 lets the episode be located from info.json instead; indexes that
610
+ * start elsewhere (10, 20, 30, ... in behavior-1k) keep the package's lookup.
611
+ */
612
+ function isV2IndexContiguous(
613
+ repoId: string,
614
+ totalEpisodes: number,
615
+ ): Promise<boolean> {
616
+ const url = leRobotDataset(repoId).fileUrl("meta/episodes.jsonl");
617
+ let contiguous = v2ContiguousIndexes.get(url);
618
+ if (!contiguous) {
619
+ const read = (start: number, end?: number) =>
620
+ fetchRange(url, start, end, {
621
+ additionalFetchHeaders: authHeaders(),
622
+ }).then(({ bytes }) => new TextDecoder().decode(bytes));
623
+ const headRead = read(0, 4095);
624
+ /// The Hub answers a suffix longer than the file with a 416; the head then holds all of it.
625
+ const tailRead = read(-4096).catch((error) => {
626
+ if (error instanceof HttpError && error.status === 416) return headRead;
627
+ throw error;
628
+ });
629
+ contiguous = Promise.all([headRead, tailRead]).then(
630
+ ([head, tail]) => {
631
+ try {
632
+ const first = JSON.parse(head.split("\n")[0]);
633
+ const last = JSON.parse(tail.trimEnd().split("\n").pop() ?? "");
634
+ return (
635
+ first.episode_index === 0 &&
636
+ last.episode_index === totalEpisodes - 1
637
+ );
638
+ } catch {
639
+ /// A line longer than the 4 KB window: the answer would not change on a retry.
640
+ return false;
641
+ }
642
+ },
643
+ () => {
644
+ v2ContiguousIndexes.delete(url);
645
+ return false;
646
+ },
647
+ );
648
+ v2ContiguousIndexes.set(url, contiguous);
649
+ }
650
+ return contiguous;
651
+ }
652
+
653
+ /**
654
+ * Enough of the package's episode for getEpisodeDataV2, built from info.json alone. Only
655
+ * `videos[].url` and `data` are filled in: `length`, `durationSec`, `tasks` and `toSec` stay empty,
656
+ * since that path takes its duration from the chart timestamps.
657
+ */
658
+ async function v2EpisodeFromTemplates(
659
+ repoId: string,
660
+ episodeId: number,
661
+ ): Promise<LeRobotEpisode> {
662
+ const dataset = leRobotDataset(repoId);
663
+ const info = await dataset.info();
664
+ /// The package's lookup keeps answering for ids that name no episode ("not found" past the end).
665
+ if (
666
+ !Number.isInteger(episodeId) ||
667
+ episodeId < 0 ||
668
+ episodeId >= info.totalEpisodes
669
+ ) {
670
+ return loadLeRobotEpisode(repoId, episodeId);
671
+ }
672
+ const { videoPath } = info;
673
+ const vars = {
674
+ episode_chunk: Math.floor(episodeId / info.chunksSize),
675
+ episode_index: episodeId,
676
+ };
677
+ return {
678
+ index: episodeId,
679
+ length: 0,
680
+ durationSec: 0,
681
+ tasks: [],
682
+ videos:
683
+ videoPath === undefined
684
+ ? []
685
+ : info.cameras.map((camera) => ({
686
+ cameraKey: camera.key,
687
+ url: dataset.fileUrl(
688
+ formatPathTemplate(videoPath, { ...vars, video_key: camera.key }),
689
+ ),
690
+ fromSec: 0,
691
+ toSec: 0,
692
+ })),
693
+ data: {
694
+ url: dataset.fileUrl(formatPathTemplate(info.dataPath, vars)),
695
+ /// The file holds this one episode, and hyparquet stops at its last row.
696
+ fromRow: 0,
697
+ toRow: Number.MAX_SAFE_INTEGER,
698
+ },
699
+ };
700
+ }
701
+
702
  // Legacy v2.x data loading
703
  async function getEpisodeDataV2(
704
  repoId: string,
 
729
  .map((x) => parseInt(x.trim(), 10))
730
  .filter((x) => !isNaN(x));
731
 
732
+ /// Both ways of locating the episode need the package's info.json; fetch it alongside the probe.
733
+ const probe =
734
+ episodeId >= V2_PROBE_FROM_EPISODE ||
735
+ v2ContiguousIndexes.has(
736
+ leRobotDataset(repoId).fileUrl("meta/episodes.jsonl"),
737
+ );
738
+ const [contiguous] = await Promise.all([
739
+ probe && isV2IndexContiguous(repoId, info.total_episodes),
740
+ leRobotDataset(repoId).info(),
741
+ ]);
742
+ const leRobotEpisode = contiguous
743
+ ? await v2EpisodeFromTemplates(repoId, episodeId)
744
+ : await loadLeRobotEpisode(repoId, episodeId);
745
  const videosInfo =
746
  info.video_path !== null ? toVideosInfo(leRobotEpisode, info, false) : [];
747
 
src/components/urdf-viewer.tsx CHANGED
@@ -57,6 +57,104 @@ function loadStlGeometry(url: string): Promise<THREE.BufferGeometry> {
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
@@ -410,93 +508,14 @@ function RobotScene({
410
  return group;
411
  };
412
  loader.loadMeshCb = (url, mgr, onLoad) => {
413
- // DAE (Collada) files — ColladaLoader yields whatever the .dae author
414
- // baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
415
- // OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
416
- // lights caused the scene to look pure-white everywhere regardless of
417
- // our own lighting, and the flat materials looked cartoonish. We strip
418
- // both and rebuild every mesh with a MeshStandardMaterial bucketed into
419
- // one of three archetypes (carbon-black, brushed metal, off-white paint)
420
- // based on the original base-color lightness.
421
  if (url.endsWith(".dae")) {
422
- const colladaLoader = new ColladaLoader(mgr);
423
- colladaLoader.load(
424
- url,
425
- (collada) => {
426
- if (isOpenArm) {
427
- const strayLights: THREE.Object3D[] = [];
428
- collada.scene.traverse((child) => {
429
- if (
430
- (child as THREE.Light).isLight &&
431
- !(child instanceof THREE.AmbientLight)
432
- ) {
433
- strayLights.push(child);
434
- }
435
- });
436
- for (const l of strayLights) l.parent?.remove(l);
437
-
438
- collada.scene.traverse((child) => {
439
- if (!(child instanceof THREE.Mesh) || !child.material) return;
440
-
441
- const originals = Array.isArray(child.material)
442
- ? child.material
443
- : [child.material];
444
-
445
- const rebuilt = originals.map((orig) => {
446
- const srcColor =
447
- (orig as THREE.MeshStandardMaterial).color ??
448
- new THREE.Color("#c0c4cc");
449
- const hsl = { h: 0, s: 0, l: 0 };
450
- srcColor.getHSL(hsl);
451
-
452
- // Archetype classification by original lightness.
453
- let color: THREE.Color;
454
- let metalness: number;
455
- let roughness: number;
456
- let envMapIntensity: number;
457
- if (hsl.l < 0.3) {
458
- // Carbon / anodised structural parts
459
- color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
460
- metalness = 0.15;
461
- roughness = 0.75;
462
- envMapIntensity = 0.6;
463
- } else if (hsl.l < 0.7) {
464
- // Brushed metal joint collars / accents
465
- color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
466
- metalness = 0.75;
467
- roughness = 0.35;
468
- envMapIntensity = 1.1;
469
- } else {
470
- // Off-white painted plates
471
- color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
472
- metalness = 0.1;
473
- roughness = 0.5;
474
- envMapIntensity = 0.9;
475
- }
476
-
477
- const mat = new THREE.MeshStandardMaterial({
478
- color,
479
- metalness,
480
- roughness,
481
- envMapIntensity,
482
- side: THREE.DoubleSide,
483
- });
484
- orig.dispose?.();
485
- return mat;
486
- });
487
-
488
- child.material = Array.isArray(child.material)
489
- ? rebuilt
490
- : rebuilt[0];
491
- child.castShadow = true;
492
- child.receiveShadow = true;
493
- });
494
- }
495
- onLoad(collada.scene);
496
- },
497
- undefined,
498
- (err) => onLoad(new THREE.Object3D(), err as Error),
499
- );
500
  return;
501
  }
502
  // STL files — apply final PBR materials directly here. We used to do a
 
57
  return loading;
58
  }
59
 
60
+ /// One download and parse per DAE file per session: the OpenArm URDF references finger.dae 4 times
61
+ /// and each arm link twice, and the viewer remounts on every visit to the 3D Replay tab.
62
+ const daeSceneCache = new Map<string, Promise<THREE.Object3D>>();
63
+
64
+ function loadDaeScene(url: string): Promise<THREE.Object3D> {
65
+ let loading = daeSceneCache.get(url);
66
+ if (!loading) {
67
+ loading = new ColladaLoader()
68
+ .loadAsync(url)
69
+ .then((collada) => {
70
+ // DAE (Collada) files — ColladaLoader yields whatever the .dae author
71
+ // baked in: flat MeshPhongMaterial/MeshBasicMaterial colors plus, in
72
+ // OpenArm's case, ~23 per-file PointLight/SpotLight nodes. The stray
73
+ // lights caused the scene to look pure-white everywhere regardless of
74
+ // our own lighting, and the flat materials looked cartoonish. We strip
75
+ // both and rebuild every mesh with a MeshStandardMaterial bucketed into
76
+ // one of three archetypes (carbon-black, brushed metal, off-white paint)
77
+ // based on the original base-color lightness.
78
+ if (url.includes("openarm")) {
79
+ const strayLights: THREE.Object3D[] = [];
80
+ collada.scene.traverse((child) => {
81
+ if (
82
+ (child as THREE.Light).isLight &&
83
+ !(child instanceof THREE.AmbientLight)
84
+ ) {
85
+ strayLights.push(child);
86
+ }
87
+ });
88
+ for (const l of strayLights) l.parent?.remove(l);
89
+
90
+ collada.scene.traverse((child) => {
91
+ if (!(child instanceof THREE.Mesh) || !child.material) return;
92
+
93
+ const originals = Array.isArray(child.material)
94
+ ? child.material
95
+ : [child.material];
96
+
97
+ const rebuilt = originals.map((orig) => {
98
+ const srcColor =
99
+ (orig as THREE.MeshStandardMaterial).color ??
100
+ new THREE.Color("#c0c4cc");
101
+ const hsl = { h: 0, s: 0, l: 0 };
102
+ srcColor.getHSL(hsl);
103
+
104
+ // Archetype classification by original lightness.
105
+ let color: THREE.Color;
106
+ let metalness: number;
107
+ let roughness: number;
108
+ let envMapIntensity: number;
109
+ if (hsl.l < 0.3) {
110
+ // Carbon / anodised structural parts
111
+ color = new THREE.Color().setHSL(hsl.h, 0.02, 0.09);
112
+ metalness = 0.15;
113
+ roughness = 0.75;
114
+ envMapIntensity = 0.6;
115
+ } else if (hsl.l < 0.7) {
116
+ // Brushed metal joint collars / accents
117
+ color = new THREE.Color().setHSL(hsl.h, 0.04, 0.42);
118
+ metalness = 0.75;
119
+ roughness = 0.35;
120
+ envMapIntensity = 1.1;
121
+ } else {
122
+ // Off-white painted plates
123
+ color = new THREE.Color().setHSL(hsl.h, 0.03, 0.6);
124
+ metalness = 0.1;
125
+ roughness = 0.5;
126
+ envMapIntensity = 0.9;
127
+ }
128
+
129
+ const mat = new THREE.MeshStandardMaterial({
130
+ color,
131
+ metalness,
132
+ roughness,
133
+ envMapIntensity,
134
+ side: THREE.DoubleSide,
135
+ });
136
+ orig.dispose?.();
137
+ return mat;
138
+ });
139
+
140
+ child.material = Array.isArray(child.material)
141
+ ? rebuilt
142
+ : rebuilt[0];
143
+ child.castShadow = true;
144
+ child.receiveShadow = true;
145
+ });
146
+ }
147
+ return collada.scene;
148
+ })
149
+ .catch((err) => {
150
+ daeSceneCache.delete(url);
151
+ throw err;
152
+ });
153
+ daeSceneCache.set(url, loading);
154
+ }
155
+ return loading;
156
+ }
157
+
158
  // URDFs + meshes are hosted in the Hub bucket at
159
  // https://huggingface.co/buckets/lerobot/robot-urdfs. URDFLoader resolves
160
  // relative mesh paths against the URDF's own URL, so the bucket layout
 
508
  return group;
509
  };
510
  loader.loadMeshCb = (url, mgr, onLoad) => {
 
 
 
 
 
 
 
 
511
  if (url.endsWith(".dae")) {
512
+ /// URDFLoader re-parents and re-orients what it is handed, so it gets a clone (sharing
513
+ /// geometry and materials) and the cached scene stays intact.
514
+ mgr.itemStart(url);
515
+ loadDaeScene(url)
516
+ .then((daeScene) => onLoad(daeScene.clone()))
517
+ .catch((err) => onLoad(new THREE.Object3D(), err as Error))
518
+ .finally(() => mgr.itemEnd(url));
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
519
  return;
520
  }
521
  // STL files — apply final PBR materials directly here. We used to do a