pepijn223 HF Staff commited on
Commit
0421ffe
·
verified ·
1 Parent(s): b00f3e3

Add standalone DROID-W SLAM with camera-frame poses and manual lens intrinsics

Browse files
README.md CHANGED
@@ -41,6 +41,29 @@ tags:
41
 
42
  ![LeRobot Annotation Studio — robot video to language](assets/studio-cover.png)
43
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
44
  **Hand actions** uses EgoHand with native SOMA/MHR hands and
45
  **DROID-W + MoGe Small** inside the existing annotation wizard. Choose a human-POV
46
  dataset camera and an HF Jobs GPU. Measured calibration comes from dataset
 
41
 
42
  ![LeRobot Annotation Studio — robot video to language](assets/studio-cover.png)
43
 
44
+ ## Standalone SLAM for wrist and handheld cameras
45
+
46
+ Choose **SLAM camera poses** to run the frozen DROID-W + MoGe Small camera
47
+ pipeline without hand reconstruction or a hosted language model. It runs on an
48
+ HF Jobs GPU and writes a private LeRobot v3 copy with every original action,
49
+ joint state, timestamp and camera retained. The added
50
+ `annotation.camera_initial_from_optical` transform expresses each camera pose in
51
+ the first optical camera frame of that episode: X right, Y down, Z forward.
52
+ `annotation.camera_pose_sampled` distinguishes tracked frames from interpolation.
53
+ The default tracks every source frame; 10 Hz sampling is an explicit preview.
54
+
55
+ Camera calibration can come from dataset metadata, manually entered pinhole or
56
+ OpenCV fisheye parameters, or experimental MoGe focal estimation. Manual inputs
57
+ must match the selected video's resolution and are recorded in the run report.
58
+ MoGe focal estimation assumes a centered pinhole camera without distortion.
59
+ MoGe's learned metric scale is never fitted to evaluation trajectories.
60
+
61
+ The 200 FPS target refers to actual tracked frames divided by camera-worker
62
+ wall time. Decode, intrinsics estimation, interpolation, setup and export are
63
+ reported separately in `annotation_studio/slam.json`; cold setup and input
64
+ copying count toward billed Job time. Throughput and accuracy are experimental;
65
+ the current benchmark receipt is exposed at `/api/slam-pipeline`.
66
+
67
  **Hand actions** uses EgoHand with native SOMA/MHR hands and
68
  **DROID-W + MoGe Small** inside the existing annotation wizard. Choose a human-POV
69
  dataset camera and an HF Jobs GPU. Measured calibration comes from dataset
flora-worker-image.json CHANGED
@@ -25,7 +25,7 @@
25
  },
26
  "expected_code": {
27
  "worker.py": "c4f53f78e246e2e42cc8348d5ed23954badc1c027bceb579ff24be0db89a9110",
28
- "subset.py": "f82fa98d325780b17d904a2a4f1cd8d6f37cd95f3e8b87f3d26185eb34a73632",
29
  "flora_captioner.py": "56a2ce4e5beda1c57948a8293bd53f5871f196ff0cbf91fa18e3858d51eea998",
30
  "flora_acceleration.py": "782a49a896bfe90d5ca4853ee963287dd861a87f09e28bc52efaecca3cdaeaa6",
31
  "flora_batcher.py": "38e03e2032af8d5f3ed34980cc4d89927fe70c9742aa2ce13f4d0ccdd6fba727",
 
25
  },
26
  "expected_code": {
27
  "worker.py": "c4f53f78e246e2e42cc8348d5ed23954badc1c027bceb579ff24be0db89a9110",
28
+ "subset.py": "c32c62ca23155c602a4c92e67a76613bc2c4dec4ddf9dd4dc554600bb99034d3",
29
  "flora_captioner.py": "56a2ce4e5beda1c57948a8293bd53f5871f196ff0cbf91fa18e3858d51eea998",
30
  "flora_acceleration.py": "782a49a896bfe90d5ca4853ee963287dd861a87f09e28bc52efaecca3cdaeaa6",
31
  "flora_batcher.py": "38e03e2032af8d5f3ed34980cc4d89927fe70c9742aa2ce13f4d0ccdd6fba727",
frontend/src/SlamSettings.tsx ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type SlamOptions = {
2
+ compute: string;
3
+ calibration_mode: "dataset" | "estimate-moge" | "manual";
4
+ sample_mode: "all-frames" | "10hz";
5
+ intrinsics: {model: string; width: number; height: number; fx: number; fy: number; cx: number; cy: number; distortion: number[]};
6
+ };
7
+ export const defaultSlam: SlamOptions = {
8
+ compute: "rtx-pro-6000", calibration_mode: "dataset", sample_mode: "all-frames",
9
+ intrinsics: {model: "pinhole", width: 480, height: 360, fx: 0, fy: 0, cx: 240, cy: 180, distortion: []},
10
+ };
11
+ export default function SlamSettings({value, onChange}: {value: SlamOptions; onChange: (value: SlamOptions) => void}) {
12
+ return <section className="card step-card" aria-label="SLAM settings">
13
+ <h3>DROID-W + MoGe camera tracking</h3>
14
+ <p className="field-help">Camera trajectory in the first camera frame: X right, Y down, Z forward. Original actions, joint states and frames are retained.</p>
15
+ <label className="field-label">HF Jobs GPU<select value={value.compute} onChange={e=>onChange({...value,compute:e.target.value})}>
16
+ <option value="rtx-pro-6000">RTX PRO 6000 · 96 GB</option><option value="h200">H200 · 141 GB</option><option value="a100-large">A100 · 80 GB</option>
17
+ </select></label>
18
+ <label className="field-label">Camera intrinsics<select value={value.calibration_mode} onChange={e=>onChange({...value,calibration_mode:e.target.value as SlamOptions["calibration_mode"]})}>
19
+ <option value="dataset">Use dataset calibration</option><option value="manual">Enter measured intrinsics</option><option value="estimate-moge">Estimate with MoGe · experimental</option>
20
+ </select></label>
21
+ {value.calibration_mode === "manual" && <div className="viz-grid">
22
+ {(["width","height","fx","fy","cx","cy"] as const).map(key=><label className="field-label" key={key}>{key} (pixels)<input type="number" step="any" value={value.intrinsics[key]} onChange={e=>onChange({...value,intrinsics:{...value.intrinsics,[key]:Number(e.target.value)}})}/></label>)}
23
+ <label className="field-label">Lens model<select value={value.intrinsics.model} onChange={e=>onChange({...value,intrinsics:{...value.intrinsics,model:e.target.value,distortion:[]}})}><option value="pinhole">Pinhole / OpenCV distortion</option><option value="opencv_fisheye">OpenCV fisheye</option></select></label>
24
+ <label className="field-label">Distortion coefficients (comma separated)<input value={value.intrinsics.distortion.join(",")} onChange={e=>onChange({...value,intrinsics:{...value.intrinsics,distortion:e.target.value ? e.target.value.split(",").map(Number) : []}})}/></label>
25
+ </div>}
26
+ <label className="field-label">Frames to track<select value={value.sample_mode} onChange={e=>onChange({...value,sample_mode:e.target.value as SlamOptions["sample_mode"]})}>
27
+ <option value="all-frames">Every source frame</option><option value="10hz">10 Hz preview · interpolate remaining frames</option>
28
+ </select></label>
29
+ <p className="field-help">MoGe supplies a learned metric scale. Estimated intrinsics assume a centered pinhole lens with no distortion. The 200 FPS target is measured on tracked frames; interpolation is reported separately.</p>
30
+ </section>;
31
+ }
frontend/src/main.tsx CHANGED
@@ -38,6 +38,7 @@ import {
38
  import "./style.css";
39
  import "./wizard.css";
40
  import AnnotationExamples from "./AnnotationExamples";
 
41
  import JobsView, { type Job, type Progress } from "./JobsView";
42
  import HandActionSettings, {
43
  HandPipelineExplanation,
@@ -54,8 +55,10 @@ type Feature =
54
  | "Interjections & speech"
55
  | "Visual questions"
56
  | "Human demo videos"
57
- | "Hand actions";
 
58
  const HANDS: Feature = "Hand actions";
 
59
  const HUMAN_VIDEOS: Feature = "Human demo videos";
60
  type Options = {
61
  model: string;
@@ -72,6 +75,7 @@ type Options = {
72
  human_video_segments: number;
73
  human_video_parallel: number;
74
  hand_actions: HandOptions;
 
75
  };
76
  type PromptTemplate = {
77
  key: string;
@@ -186,6 +190,7 @@ const defaults: Options = {
186
  human_video_segments: 0,
187
  human_video_parallel: 8,
188
  hand_actions: defaultHands,
 
189
  };
190
  const definitions: {
191
  name: Feature;
@@ -259,6 +264,7 @@ function initial() {
259
  return {
260
  ...defaults,
261
  ...saved,
 
262
  hand_actions: {
263
  ...defaultHands,
264
  compute: saved.hand_actions?.compute ?? defaultHands.compute,
@@ -357,7 +363,8 @@ function App() {
357
  );
358
  const usesFlora = options.captioner === "flora";
359
  const usesHands = options.features.includes(HANDS);
360
- const handsOnly = usesHands && options.features.length === 1;
 
361
  const floraNeedsHelper =
362
  usesFlora &&
363
  (options.features.some(
@@ -596,6 +603,12 @@ function App() {
596
  let features = on
597
  ? [...options.features, name]
598
  : options.features.filter((x) => x !== name);
 
 
 
 
 
 
599
  // Videos are generated per subtask, so they need the subtask annotations.
600
  if (on && name === HUMAN_VIDEOS && !features.includes("Subtasks"))
601
  features = ["Subtasks", ...features];
@@ -1273,6 +1286,11 @@ function App() {
1273
  <HandPipelineExplanation />
1274
  </section>
1275
  )}
 
 
 
 
 
1276
  {step === 2 && usesHands && (
1277
  <HandActionSettings
1278
  value={options.hand_actions}
@@ -1490,7 +1508,7 @@ function App() {
1490
  <div>
1491
  <span>Model</span>
1492
  <strong>
1493
- {handsOnly
1494
  ? "EgoHand / SOMA · DROID-W + MoGe · HF Jobs"
1495
  : usesFlora
1496
  ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU"
@@ -1533,7 +1551,7 @@ function App() {
1533
  type="radio"
1534
  name="visibility"
1535
  value={value}
1536
- disabled={usesHands && value === "public"}
1537
  checked={visibility === value}
1538
  onChange={() => {
1539
  setVisibility(value);
@@ -2057,7 +2075,7 @@ function App() {
2057
  <div>
2058
  <dt>Annotation model</dt>
2059
  <dd>
2060
- {handsOnly
2061
  ? "EgoHand / SOMA · DROID-W + MoGe · HF Jobs"
2062
  : quote?.captioner === "flora"
2063
  ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU · noncommercial research"
 
38
  import "./style.css";
39
  import "./wizard.css";
40
  import AnnotationExamples from "./AnnotationExamples";
41
+ import SlamSettings, { defaultSlam, type SlamOptions } from "./SlamSettings";
42
  import JobsView, { type Job, type Progress } from "./JobsView";
43
  import HandActionSettings, {
44
  HandPipelineExplanation,
 
55
  | "Interjections & speech"
56
  | "Visual questions"
57
  | "Human demo videos"
58
+ | "Hand actions"
59
+ | "SLAM camera poses";
60
  const HANDS: Feature = "Hand actions";
61
+ const SLAM: Feature = "SLAM camera poses";
62
  const HUMAN_VIDEOS: Feature = "Human demo videos";
63
  type Options = {
64
  model: string;
 
75
  human_video_segments: number;
76
  human_video_parallel: number;
77
  hand_actions: HandOptions;
78
+ slam: SlamOptions;
79
  };
80
  type PromptTemplate = {
81
  key: string;
 
190
  human_video_segments: 0,
191
  human_video_parallel: 8,
192
  hand_actions: defaultHands,
193
+ slam: defaultSlam,
194
  };
195
  const definitions: {
196
  name: Feature;
 
264
  return {
265
  ...defaults,
266
  ...saved,
267
+ slam: {...defaultSlam, ...saved.slam},
268
  hand_actions: {
269
  ...defaultHands,
270
  compute: saved.hand_actions?.compute ?? defaultHands.compute,
 
363
  );
364
  const usesFlora = options.captioner === "flora";
365
  const usesHands = options.features.includes(HANDS);
366
+ const usesSlam = options.features.includes(SLAM);
367
+ const handsOnly = (usesHands || usesSlam) && options.features.length === 1;
368
  const floraNeedsHelper =
369
  usesFlora &&
370
  (options.features.some(
 
603
  let features = on
604
  ? [...options.features, name]
605
  : options.features.filter((x) => x !== name);
606
+ if (on && name === SLAM) {
607
+ setVisibility("private");
608
+ change({features: [SLAM], captioner: "selected-vlm"});
609
+ return;
610
+ }
611
+ if (on) features = features.filter(x=>x !== SLAM);
612
  // Videos are generated per subtask, so they need the subtask annotations.
613
  if (on && name === HUMAN_VIDEOS && !features.includes("Subtasks"))
614
  features = ["Subtasks", ...features];
 
1286
  <HandPipelineExplanation />
1287
  </section>
1288
  )}
1289
+ {step === 1 && <section className={`card step-card ${usesSlam ? "selected" : ""}`} aria-label="SLAM camera poses">
1290
+ <h3>SLAM camera poses</h3><p className="field-help">Track wrist or handheld cameras with DROID-W + MoGe. Export 3D trajectories in the initial camera frame for Grabette and robot datasets.</p>
1291
+ <label className="annotation-choice"><input type="checkbox" checked={usesSlam} onChange={()=>toggle(SLAM)}/><span><strong>DROID-W + MoGe Small</strong><small>Standalone GPU pipeline · preserves source actions and joint states</small></span></label>
1292
+ </section>}
1293
+ {step === 2 && usesSlam && <SlamSettings value={options.slam} onChange={slam=>change({slam})}/>}
1294
  {step === 2 && usesHands && (
1295
  <HandActionSettings
1296
  value={options.hand_actions}
 
1508
  <div>
1509
  <span>Model</span>
1510
  <strong>
1511
+ {usesSlam ? "DROID-W + MoGe · HF Jobs" : handsOnly
1512
  ? "EgoHand / SOMA · DROID-W + MoGe · HF Jobs"
1513
  : usesFlora
1514
  ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU"
 
1551
  type="radio"
1552
  name="visibility"
1553
  value={value}
1554
+ disabled={(usesHands || usesSlam) && value === "public"}
1555
  checked={visibility === value}
1556
  onChange={() => {
1557
  setVisibility(value);
 
2075
  <div>
2076
  <dt>Annotation model</dt>
2077
  <dd>
2078
+ {usesSlam ? "DROID-W + MoGe · HF Jobs" : handsOnly
2079
  ? "EgoHand / SOMA · DROID-W + MoGe · HF Jobs"
2080
  : quote?.captioner === "flora"
2081
  ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU · noncommercial research"
hand-worker-image.json CHANGED
@@ -70,7 +70,7 @@
70
  "hand_actions.py": "a0c448022c2933560e27f93d371466eb4aaddb979330512412eb08ca6ae2de8c",
71
  "hand_setup.py": "e4e40e83510e724a8ab9b08f77dbad2d64401051cf6849ed03d9b572b18e2bdb",
72
  "pipeline-version.json": "ee3868635d88f578c805ea6afa13a4587989bc8586fcb81a1127cdeb2d59bafe",
73
- "studio.py": "c5328f0e36fd327699427b845f088b0af35b2cdbc19d480905edbdcec6014fd6",
74
  "worker.py": "c4f53f78e246e2e42cc8348d5ed23954badc1c027bceb579ff24be0db89a9110"
75
  }
76
  }
 
70
  "hand_actions.py": "a0c448022c2933560e27f93d371466eb4aaddb979330512412eb08ca6ae2de8c",
71
  "hand_setup.py": "e4e40e83510e724a8ab9b08f77dbad2d64401051cf6849ed03d9b572b18e2bdb",
72
  "pipeline-version.json": "ee3868635d88f578c805ea6afa13a4587989bc8586fcb81a1127cdeb2d59bafe",
73
+ "studio.py": "fa222a7ad7bf57cdfa7e3835ee5e1773763ead41fa88b5140c3631a732283cb6",
74
  "worker.py": "c4f53f78e246e2e42cc8348d5ed23954badc1c027bceb579ff24be0db89a9110"
75
  }
76
  }
server.py CHANGED
@@ -23,6 +23,7 @@ from model_catalog import DEFAULT_MODEL, public_models
23
  from prompt_config import public_catalog, validate_prompts
24
  from pydantic import BaseModel, Field
25
  from reporting import annotation_url, episode_progress, hand_visualizer_url, usage_costs
 
26
  from studio import COPY, FEATURES, launch, prepare, read_cameras
27
 
28
  app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
@@ -47,13 +48,21 @@ class HandActionInput(BaseModel):
47
  calibration_mode: Literal["dataset", "estimate-moge"] = "dataset"
48
 
49
 
 
 
 
 
 
 
 
50
  class EstimateInput(DatasetInput):
51
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
52
  captioner: Literal["selected-vlm", "flora"] = "selected-vlm"
53
  start: int = Field(default=0, ge=0)
54
  count: int = Field(default=100, ge=0)
55
- features: list[str] = Field(min_length=1, max_length=8)
56
  hand_actions: HandActionInput | None = None
 
57
  camera: str | None = Field(default=None, max_length=200)
58
  hz: float = 0.1
59
  hours: int = 4
@@ -282,6 +291,7 @@ def calculate(options: EstimateInput, request: Request):
282
  human_video_parallel=options.human_video_parallel,
283
  captioner=options.captioner,
284
  hand_actions=options.hand_actions.model_dump() if options.hand_actions else None,
 
285
  )
286
  except Exception as exc:
287
  raise HTTPException(400, safe_error(exc)) from None
@@ -328,6 +338,11 @@ def prompts():
328
  return public_catalog()
329
 
330
 
 
 
 
 
 
331
  @app.post("/api/prompts/validate")
332
  def check_prompts(options: PromptInput, request: Request):
333
  check_origin(request)
 
23
  from prompt_config import public_catalog, validate_prompts
24
  from pydantic import BaseModel, Field
25
  from reporting import annotation_url, episode_progress, hand_visualizer_url, usage_costs
26
+ from slam_pipeline import catalog as slam_catalog
27
  from studio import COPY, FEATURES, launch, prepare, read_cameras
28
 
29
  app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
 
48
  calibration_mode: Literal["dataset", "estimate-moge"] = "dataset"
49
 
50
 
51
+ class SlamInput(BaseModel):
52
+ compute: Literal["h200", "a100-large", "rtx-pro-6000"] = "rtx-pro-6000"
53
+ calibration_mode: Literal["dataset", "estimate-moge", "manual"] = "dataset"
54
+ sample_mode: Literal["all-frames", "10hz"] = "all-frames"
55
+ intrinsics: dict | None = None
56
+
57
+
58
  class EstimateInput(DatasetInput):
59
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
60
  captioner: Literal["selected-vlm", "flora"] = "selected-vlm"
61
  start: int = Field(default=0, ge=0)
62
  count: int = Field(default=100, ge=0)
63
+ features: list[str] = Field(min_length=1, max_length=9)
64
  hand_actions: HandActionInput | None = None
65
+ slam: SlamInput | None = None
66
  camera: str | None = Field(default=None, max_length=200)
67
  hz: float = 0.1
68
  hours: int = 4
 
291
  human_video_parallel=options.human_video_parallel,
292
  captioner=options.captioner,
293
  hand_actions=options.hand_actions.model_dump() if options.hand_actions else None,
294
+ slam=options.slam.model_dump() if options.slam else None,
295
  )
296
  except Exception as exc:
297
  raise HTTPException(400, safe_error(exc)) from None
 
338
  return public_catalog()
339
 
340
 
341
+ @app.get("/api/slam-pipeline")
342
+ def slam_pipeline():
343
+ return slam_catalog()
344
+
345
+
346
  @app.post("/api/prompts/validate")
347
  def check_prompts(options: PromptInput, request: Request):
348
  check_origin(request)
slam-pipeline.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "version": "droid-w-camera-v1",
3
+ "coordinate_frame": "initial optical camera; X right, Y down, Z forward",
4
+ "expected_code": {
5
+ "slam_pipeline.py": "aafd39fe8272914f387b65b8e3796f99f2cadd9bb5323b36c6857be04467ce71",
6
+ "slam_job.py": "45d881331651480b87460a6abd868a3cb2ae1dcc858fe7f3a98dada87040cc68",
7
+ "slam_native.py": "148a8c5aa30ca52cbce495841f4dfccb806448cf820be0611e888f963ca2a927",
8
+ "server.py": "f6be4561676281920e447045245efc938beca86a7e71e5c71dc949151f3c79f9",
9
+ "studio.py": "fa222a7ad7bf57cdfa7e3835ee5e1773763ead41fa88b5140c3631a732283cb6",
10
+ "subset.py": "c32c62ca23155c602a4c92e67a76613bc2c4dec4ddf9dd4dc554600bb99034d3"
11
+ },
12
+ "benchmark": {
13
+ "status": "running",
14
+ "target_tracked_fps": 200,
15
+ "target_achieved": false,
16
+ "job": "https://huggingface.co/jobs/pepijn223/6adf27b01e112550ce7f570f"
17
+ }
18
+ }
slam_job.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone camera trajectory annotation; retain source actions and every frame."""
2
+
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import time
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+ from huggingface_hub import HfApi, hf_hub_download
13
+ from slam_native import close, prepare, track
14
+ from slam_pipeline import SLAM, verify_release
15
+ from studio import read_selection, relative_path
16
+ from subset import EpisodeSubset, trim_video
17
+
18
+ POSE = "annotation.camera_initial_from_optical"
19
+ SAMPLED = "annotation.camera_pose_sampled"
20
+ FEATURES = {
21
+ POSE: {
22
+ "dtype": "float64",
23
+ "shape": [4, 4],
24
+ "names": ["row", "column"],
25
+ "info": {
26
+ "convention": "initial_optical_from_current_optical",
27
+ "translation_units": "learned metric metres",
28
+ "axes": "X right, Y down, Z forward",
29
+ "world": "first camera frame of each episode",
30
+ "metric_accuracy_validated": False,
31
+ },
32
+ },
33
+ SAMPLED: {"dtype": "bool", "shape": [1], "names": None},
34
+ }
35
+
36
+
37
+ def append_poses(table, archive):
38
+ with np.load(archive, allow_pickle=False) as result:
39
+ poses, sampled = result["camera_pose"], result["sampled_indices"]
40
+ times = result["timestamp_s"]
41
+ if poses.shape != (len(table), 4, 4) or not np.isfinite(poses).all():
42
+ raise ValueError("Camera trajectory does not cover the source episode")
43
+ source_times = np.asarray(table["timestamp"].to_pylist())
44
+ if not np.allclose(source_times - source_times[0], times, atol=1e-4, rtol=0):
45
+ raise ValueError("Camera and robot-state timestamps differ")
46
+ if any(name in table.column_names for name in FEATURES):
47
+ raise ValueError("Source already contains camera pose annotations")
48
+ mask = np.zeros(len(table), dtype=bool)
49
+ mask[sampled] = True
50
+ return table.append_column(
51
+ POSE, pa.array(poses.tolist(), type=pa.list_(pa.list_(pa.float64(), 4), 4))
52
+ ).append_column(SAMPLED, pa.array(mask))
53
+
54
+
55
+ def run(manifest, work=Path("/workspace/slam-run")):
56
+ if manifest["features"] != [SLAM] or not manifest.get("slam"):
57
+ raise ValueError("Standalone SLAM requires the pinned SLAM selection")
58
+ config = manifest["slam"]
59
+ if config["release"] != verify_release():
60
+ raise ValueError("SLAM worker differs from the quoted release")
61
+ api = HfApi()
62
+ token = os.environ["HF_TOKEN"]
63
+ work.mkdir(parents=True, exist_ok=False)
64
+ source, output = work / "source", work / "dataset"
65
+ source.mkdir()
66
+ selection = read_selection(
67
+ manifest["dataset"],
68
+ manifest["start"],
69
+ manifest["count"],
70
+ token=token,
71
+ revision=manifest["dataset_revision"],
72
+ )
73
+ rows, info = selection["episodes"], selection["info"]
74
+ if (
75
+ hashlib.sha256(json.dumps([r["episode_index"] for r in rows]).encode()).hexdigest()
76
+ != manifest["episode_ids_sha256"]
77
+ ):
78
+ raise ValueError("Source episode selection differs from the quote")
79
+
80
+ def download(name):
81
+ name = relative_path(name)
82
+ return Path(
83
+ hf_hub_download(
84
+ manifest["dataset"],
85
+ name,
86
+ repo_type="dataset",
87
+ revision=manifest["dataset_revision"],
88
+ local_dir=source,
89
+ token=token,
90
+ )
91
+ )
92
+
93
+ for name in ("meta/info.json", "meta/tasks.parquet"):
94
+ download(name)
95
+ metadata = [
96
+ x.path
97
+ for x in api.list_repo_tree(
98
+ manifest["dataset"],
99
+ path_in_repo="meta/episodes",
100
+ recursive=True,
101
+ repo_type="dataset",
102
+ revision=manifest["dataset_revision"],
103
+ )
104
+ if x.path.endswith(".parquet")
105
+ ]
106
+ for name in metadata:
107
+ download(name)
108
+ writer = EpisodeSubset(source, output, [r["episode_index"] for r in rows])
109
+ api.create_repo(
110
+ manifest["output"],
111
+ repo_type="dataset",
112
+ private=manifest.get("visibility", "private") == "private",
113
+ exist_ok=False,
114
+ )
115
+ setup_start = time.perf_counter()
116
+ prepare(config["runtime"])
117
+ results = {
118
+ "pipeline": "DROID-W + MoGe Small",
119
+ "manifest": manifest,
120
+ "status": "running",
121
+ "expected_episodes": len(rows),
122
+ "written_episode_ids": [],
123
+ "usage": {"measured_inference_usd": 0},
124
+ "setup_seconds": time.perf_counter() - setup_start,
125
+ "episodes": [],
126
+ }
127
+ deadline = (
128
+ float(os.environ.get("ANNOTATION_DEADLINE_EPOCH", "0"))
129
+ or time.time() + manifest["costs"]["hours"] * 3600 - 60
130
+ )
131
+ try:
132
+ for row in rows:
133
+ if time.time() >= deadline:
134
+ raise TimeoutError("SLAM Job reached its quoted time limit")
135
+ episode = int(row["episode_index"])
136
+ directory = work / f"episode-{episode}"
137
+ directory.mkdir()
138
+ data = download(
139
+ info["data_path"].format(
140
+ chunk_index=int(row["data/chunk_index"]), file_index=int(row["data/file_index"])
141
+ )
142
+ )
143
+ table = pq.read_table(data, filters=[("episode_index", "=", episode)])
144
+ for camera in selection["cameras"]:
145
+ download(
146
+ info["video_path"].format(
147
+ video_key=camera,
148
+ chunk_index=int(row[f"videos/{camera}/chunk_index"]),
149
+ file_index=int(row[f"videos/{camera}/file_index"]),
150
+ )
151
+ )
152
+ camera = manifest["camera"]
153
+ video = directory / "episode.mp4"
154
+ source_video = source / info["video_path"].format(
155
+ video_key=camera,
156
+ chunk_index=int(row[f"videos/{camera}/chunk_index"]),
157
+ file_index=int(row[f"videos/{camera}/file_index"]),
158
+ )
159
+ trim_video(
160
+ source_video,
161
+ video,
162
+ float(row[f"videos/{camera}/from_timestamp"]),
163
+ int(row["length"]),
164
+ float(info["fps"]),
165
+ lossless=True,
166
+ )
167
+ archive, report = track(
168
+ video, directory / "native", config["calibration"], config["runtime"], config["sample_fps"]
169
+ )
170
+ annotated = append_poses(table, archive)
171
+ new_id, _ = writer.add(episode, annotated, extra_features=FEATURES)
172
+ results["episodes"].append(
173
+ {"source_episode_index": episode, "episode_index": new_id, "status": "completed", **report}
174
+ )
175
+ results["written_episode_ids"].append(new_id)
176
+ target = output / "annotation_studio"
177
+ target.mkdir(exist_ok=True)
178
+ (target / "slam.json").write_text(json.dumps(results, indent=2))
179
+ (target / "run.json").write_text(json.dumps(results, indent=2))
180
+ (target / "progress.json").write_text(
181
+ json.dumps(
182
+ {
183
+ "status": "running",
184
+ "episodes_completed": len(results["episodes"]),
185
+ "episodes_total": len(rows),
186
+ }
187
+ )
188
+ )
189
+ api.upload_folder(
190
+ repo_id=manifest["output"],
191
+ repo_type="dataset",
192
+ folder_path=output,
193
+ commit_message=f"SLAM camera poses: episode {episode}",
194
+ )
195
+ finally:
196
+ close()
197
+ results["status"] = "completed"
198
+ (output / "annotation_studio/run.json").write_text(json.dumps(results, indent=2))
199
+ (output / "annotation_studio/slam.json").write_text(json.dumps(results, indent=2))
200
+ (output / "annotation_studio/progress.json").write_text(
201
+ json.dumps({"status": "completed", "episodes_completed": len(rows), "episodes_total": len(rows)})
202
+ )
203
+ (output / "README.md").write_text(
204
+ f"---\nlicense: {manifest.get('source_license') or 'other'}\n---\n\n# DROID-W camera trajectories\n\nSource: {manifest['dataset']} at {manifest['dataset_revision']}.\n\nOriginal joint states and actions are retained. Camera poses use a learned metric prior; camera mounting and metric accuracy require validation. Sampling masks distinguish tracked and interpolated frames. See `annotation_studio/slam.json` for provenance, calibration and timings.\n"
205
+ )
206
+ api.upload_folder(
207
+ repo_id=manifest["output"],
208
+ repo_type="dataset",
209
+ folder_path=output,
210
+ commit_message="Complete SLAM annotation",
211
+ )
212
+ api.create_tag(manifest["output"], repo_type="dataset", tag="v3.0", exist_ok=True)
213
+
214
+
215
+ if __name__ == "__main__":
216
+ run(json.loads(os.environ["ANNOTATION_MANIFEST"]))
slam_native.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera-only access to Studio's frozen DROID-W + MoGe runtime."""
2
+
3
+ import json
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import time
8
+ from pathlib import Path
9
+ from types import SimpleNamespace
10
+
11
+ import numpy as np
12
+
13
+
14
+ def prepare(profile):
15
+ from hand_setup import configure_library_paths, verify_image
16
+
17
+ verify_image(profile)
18
+ backend = profile["backend"]
19
+ configure_library_paths(backend)
20
+ os.environ["LD_LIBRARY_PATH"] = os.environ["DROID_LIBRARY_PATH"]
21
+ env = dict(os.environ, NVIDIA_TF32_OVERRIDE="0", HF_HUB_OFFLINE="1", TRANSFORMERS_OFFLINE="1")
22
+ env.pop("HF_TOKEN", None)
23
+ env["PYTHONPATH"] = "/opt/camera-adapter/src:/workspace/source/src:/workspace/app"
24
+ for operation in ("--check-solver", "--build", "--check-contexts"):
25
+ subprocess.run(
26
+ [
27
+ backend["droid_python"],
28
+ str(Path(__file__).with_name("hand_setup.py")),
29
+ operation,
30
+ json.dumps(backend),
31
+ ],
32
+ env=env,
33
+ check=True,
34
+ )
35
+
36
+
37
+ def track(video, destination, calibration, profile, sample_fps=10.0):
38
+ from lerobot_camera_runtime.egocentric.droidw_camera import estimate_rectified_camera
39
+ from lerobot_camera_runtime.egocentric.intrinsics import CameraIntrinsics
40
+ from lerobot_camera_runtime.egocentric.video import rectify_video
41
+
42
+ from lerobot.annotations.hands.config import HandBackendConfig
43
+ from lerobot.annotations.hands.intrinsics import resolve_calibration
44
+
45
+ destination = Path(destination)
46
+ destination.mkdir(parents=True, exist_ok=False)
47
+ started = time.perf_counter()
48
+ backend = HandBackendConfig(**profile["backend"])
49
+ calibration = resolve_calibration(calibration, Path(video), destination, backend)
50
+ matrix = np.array(
51
+ [[calibration["fx"], 0, calibration["cx"]], [0, calibration["fy"], calibration["cy"]], [0, 0, 1.0]]
52
+ )
53
+ intrinsics = CameraIntrinsics(
54
+ calibration["width"],
55
+ calibration["height"],
56
+ matrix,
57
+ np.array(calibration.get("distortion", [])),
58
+ calibration["model"],
59
+ Path(video),
60
+ )
61
+ decoded = rectify_video(Path(video), intrinsics, destination / "rgb")
62
+ decode_s = time.perf_counter() - started
63
+ config = SimpleNamespace(
64
+ droid_root=Path(backend.droid_root),
65
+ droid_python=Path(backend.droid_python),
66
+ droid_moge_checkpoint=Path(backend.droid_moge_checkpoint),
67
+ droid_model_cache=Path(backend.droid_model_cache),
68
+ droid_resolution=320,
69
+ droid_sample_fps=sample_fps,
70
+ )
71
+ archive, stats = estimate_rectified_camera(decoded, destination / "camera", config)
72
+ evidence = json.loads((destination / "camera/worker/output/camera/moge-ablation.json").read_text())
73
+ if (
74
+ evidence.get("mode") != "on"
75
+ or evidence.get("metric_depth_reg") is not True
76
+ or evidence.get("gt_scale_fit") is not False
77
+ or evidence.get("counts", {}).get("depth_predictions", 0) < 1
78
+ ):
79
+ raise ValueError("Camera must retain the MoGe metric prior without fitting ground-truth scale")
80
+ elapsed = time.perf_counter() - started
81
+ with np.load(archive, allow_pickle=False) as result:
82
+ poses = result["camera_pose"]
83
+ sampled = result["sampled_indices"]
84
+ if poses.shape != (decoded.frame_count, 4, 4) or not np.isfinite(poses).all():
85
+ raise ValueError("SLAM must produce finite camera poses for every source frame")
86
+ values = dict(result)
87
+ # A sequence-level coordinate frame: optical axes of the first camera pose.
88
+ poses = np.linalg.inv(poses[0]) @ poses
89
+ values["camera_pose"] = poses
90
+ np.savez_compressed(archive, **values)
91
+ report = {
92
+ **stats,
93
+ "decode_and_intrinsics_s": decode_s,
94
+ "wall_s": elapsed,
95
+ "source_fps": decoded.frame_count / elapsed,
96
+ "tracked_fps": len(sampled) / stats["model_s"],
97
+ "source_model_fps": decoded.frame_count / stats["model_s"],
98
+ "calibration": calibration,
99
+ "depth_prior": evidence,
100
+ "interpolated_frames": decoded.frame_count - len(sampled),
101
+ "throughput_target_fps": 200,
102
+ "throughput_target_met": len(sampled) / stats["model_s"] >= 200,
103
+ "accuracy_validated": False,
104
+ "pose_convention": "initial_optical_from_current_optical; X right, Y down, Z forward; learned metric scale",
105
+ }
106
+ (destination / "report.json").write_text(json.dumps(report, indent=2))
107
+ print("SLAM_RESULT " + json.dumps(report), flush=True)
108
+ return archive, report
109
+
110
+
111
+ def close():
112
+ from lerobot_camera_runtime.infra.droidw_worker import close_workers
113
+
114
+ close_workers()
115
+
116
+
117
+ if __name__ == "__main__":
118
+ request = json.loads(Path(sys.argv[1]).read_text())
119
+ try:
120
+ if request.get("prepare", True):
121
+ prepare(request["runtime"])
122
+ for item in request["clips"]:
123
+ track(
124
+ item["video"],
125
+ item["destination"],
126
+ item["calibration"],
127
+ request["runtime"],
128
+ item.get("sample_fps", 10.0),
129
+ )
130
+ finally:
131
+ close()
slam_pipeline.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Standalone SLAM selection and immutable runtime identity for Studio."""
2
+
3
+ import hashlib
4
+ import json
5
+ from copy import deepcopy
6
+ from pathlib import Path
7
+
8
+ from hand_actions import COMPUTE, prepare_hands, runtime_configuration
9
+
10
+ SLAM = "SLAM camera poses"
11
+ FILES = {"slam_pipeline.py", "slam_job.py", "slam_native.py", "server.py", "studio.py", "subset.py"}
12
+
13
+
14
+ def verify_release():
15
+ root = Path(__file__).parent
16
+ release = json.loads((root / "slam-pipeline.json").read_text())
17
+ if set(release["expected_code"]) != FILES or any(
18
+ hashlib.sha256((root / name).read_bytes()).hexdigest() != digest
19
+ for name, digest in release["expected_code"].items()
20
+ ):
21
+ raise ValueError("SLAM runtime changed; validate the release before launching")
22
+ return release
23
+
24
+
25
+ def catalog():
26
+ try:
27
+ release = verify_release()
28
+ runtime_configuration()
29
+ available, reason = True, None
30
+ except (ValueError, OSError, KeyError) as exc:
31
+ release, available, reason = {}, False, str(exc)
32
+ return {
33
+ "available": available,
34
+ "reason": reason,
35
+ "model": "DROID-W + MoGe Small",
36
+ "profiles": [{"key": k, "name": v} for k, v in COMPUTE.items()],
37
+ "benchmark": release.get("benchmark"),
38
+ "target_fps": 200,
39
+ "output": "Per-frame poses in the initial optical camera frame, source timestamps and sampling mask",
40
+ "accuracy": "Learned metric scale; validate against calibrated robot or camera poses",
41
+ }
42
+
43
+
44
+ def prepare_slam(selection, camera, options, token):
45
+ options = options or {}
46
+ if options.get("sample_mode", "all-frames") not in {"all-frames", "10hz"}:
47
+ raise ValueError("Choose all source frames or explicit 10 Hz sampling")
48
+ release = verify_release()
49
+ # Manual lens calibration applies only to this run; source metadata is retained.
50
+ prepared = deepcopy(selection)
51
+ supplied = options.get("intrinsics")
52
+ manual = options.get("calibration_mode") == "manual"
53
+ if manual:
54
+ if not supplied:
55
+ raise ValueError("Enter the measured camera intrinsics for manual calibration")
56
+ prepared["info"]["features"][camera].setdefault("info", {})["camera.intrinsics"] = supplied
57
+ options = dict(options, calibration_mode="dataset")
58
+ plan = prepare_hands(prepared, camera, options, token)
59
+ if manual:
60
+ plan["calibration"]["source"] = "user-supplied"
61
+ plan.update(
62
+ sample_fps=float(selection["info"]["fps"])
63
+ if options.get("sample_mode", "all-frames") == "all-frames"
64
+ else 10.0,
65
+ release=release,
66
+ timing={
67
+ "frames": sum(r["length"] for r in selection["episodes"]),
68
+ "planning_fps": None,
69
+ "target_fps": 200,
70
+ "basis": "HF Jobs benchmark; throughput depends on scene and GPU",
71
+ },
72
+ )
73
+ return plan
studio.py CHANGED
@@ -27,6 +27,7 @@ from model_catalog import (
27
  model_profile,
28
  )
29
  from prompt_config import TEMPLATES, validate_prompts
 
30
 
31
  MODEL = model_profile()["route"]
32
  PROVIDER = model_profile()["provider"]
@@ -40,6 +41,7 @@ FEATURES = {
40
  # Not a language style: videos are saved under annotation_studio/human_videos/.
41
  "Human demo videos": "human_video",
42
  HANDS: "hands",
 
43
  }
44
  HUMAN_VIDEOS = "Human demo videos"
45
  COPY = "Annotated dataset copy"
@@ -48,7 +50,7 @@ EXPORT = "Annotation files only" # Historical reports only; new runs always wri
48
 
49
  def needs_provider(features, prompts=None, captioner="selected-vlm"):
50
  """Native hand-only and Flora workflows never need a hosted helper."""
51
- features = set(features) - {HANDS}
52
  if not features:
53
  return False
54
  return (
@@ -206,7 +208,7 @@ def estimate(
206
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
207
  raise ValueError("Choose a supported timeout and visual-question frequency.")
208
  n, seconds = len(selection["episodes"]), selection["seconds"]
209
- needs_subtasks = bool(set(features) - {"Visual questions", HANDS})
210
  if captioner == "flora" and not needs_subtasks:
211
  raise ValueError("Flora needs an annotation type that uses subtasks.")
212
  if captioner == "flora" and "plan_subtasks" in prompts:
@@ -366,6 +368,7 @@ def prepare(
366
  human_video_parallel=None,
367
  captioner="selected-vlm",
368
  hand_actions=None,
 
369
  ):
370
  profile = model_profile(model_key)
371
  prompts = validate_prompts(prompts or {}, features)
@@ -380,10 +383,14 @@ def prepare(
380
  raise ValueError("Choose a camera in this dataset.")
381
  if captioner not in {"selected-vlm", "flora"}:
382
  raise ValueError("Choose a supported subtask captioner.")
 
 
 
383
  hands = prepare_hands(selection, camera, hand_actions, token) if HANDS in features else None
384
  if hands and captioner == "flora":
385
  raise ValueError("Run Hand actions and Flora separately so each model has the GPU memory it requires")
386
- flavor = hands["compute"] if hands else FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
 
387
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == flavor)
388
  if hardware.unit_label != "minute":
389
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
@@ -399,8 +406,8 @@ def prepare(
399
  human_video_parallel,
400
  captioner,
401
  )
402
- if hands:
403
- costs.update(compute_flavor=flavor, compute_name=COMPUTE[flavor], hand_timing=hands["timing"])
404
  return {
405
  "selection": selection,
406
  "features": list(features),
@@ -413,6 +420,7 @@ def prepare(
413
  "captioner": captioner,
414
  "captioner_profile": dict(FLORA) if captioner == "flora" else None,
415
  "hand_actions": hands,
 
416
  "human_video": {
417
  # 0 = every subtask.
418
  "segments_per_episode": human_video_segments,
@@ -509,7 +517,10 @@ def launch(
509
  ):
510
  if visibility not in ("private", "public"):
511
  raise ValueError("Choose private or public output visibility.")
512
- hands = plan.get("hand_actions") if plan else None
 
 
 
513
  if hands and visibility != "private":
514
  raise ValueError("Hand research outputs must be private")
515
  if hands and hands["runtime"] != pin_runtime(runtime_configuration()):
@@ -616,12 +627,14 @@ def launch(
616
  "count": plan["selection"]["count"],
617
  "budget": budget,
618
  "inference_budget": budget - plan["costs"]["cpu_max"],
619
- "model": "EgoHand/SOMA + DROID-W + MoGe"
620
- if set(plan["features"]) == {HANDS}
 
 
621
  else profile["route"],
622
  "provider_required": uses_provider,
623
  "subtask_models": []
624
- if set(plan["features"]) == {HANDS}
625
  else [FLORA["segmenter_id"], FLORA["model_id"]]
626
  if captioner == "flora"
627
  else [profile["model_id"]],
@@ -686,6 +699,11 @@ def launch(
686
  raise ValueError("The hand image is still building; wait for its checks before starting a Job")
687
  image = hands["runtime"]["image"]
688
  command = job_command(space, revision, timeout_minutes, captioner, hands=True)
 
 
 
 
 
689
  job = api.run_job(
690
  image=image,
691
  command=command,
 
27
  model_profile,
28
  )
29
  from prompt_config import TEMPLATES, validate_prompts
30
+ from slam_pipeline import SLAM, prepare_slam, verify_release
31
 
32
  MODEL = model_profile()["route"]
33
  PROVIDER = model_profile()["provider"]
 
41
  # Not a language style: videos are saved under annotation_studio/human_videos/.
42
  "Human demo videos": "human_video",
43
  HANDS: "hands",
44
+ SLAM: "slam",
45
  }
46
  HUMAN_VIDEOS = "Human demo videos"
47
  COPY = "Annotated dataset copy"
 
50
 
51
  def needs_provider(features, prompts=None, captioner="selected-vlm"):
52
  """Native hand-only and Flora workflows never need a hosted helper."""
53
+ features = set(features) - {HANDS, SLAM}
54
  if not features:
55
  return False
56
  return (
 
208
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
209
  raise ValueError("Choose a supported timeout and visual-question frequency.")
210
  n, seconds = len(selection["episodes"]), selection["seconds"]
211
+ needs_subtasks = bool(set(features) - {"Visual questions", HANDS, SLAM})
212
  if captioner == "flora" and not needs_subtasks:
213
  raise ValueError("Flora needs an annotation type that uses subtasks.")
214
  if captioner == "flora" and "plan_subtasks" in prompts:
 
368
  human_video_parallel=None,
369
  captioner="selected-vlm",
370
  hand_actions=None,
371
+ slam=None,
372
  ):
373
  profile = model_profile(model_key)
374
  prompts = validate_prompts(prompts or {}, features)
 
383
  raise ValueError("Choose a camera in this dataset.")
384
  if captioner not in {"selected-vlm", "flora"}:
385
  raise ValueError("Choose a supported subtask captioner.")
386
+ if SLAM in features and (set(features) != {SLAM} or captioner == "flora"):
387
+ raise ValueError("Run standalone SLAM separately from other annotations")
388
+ camera_slam = prepare_slam(selection, camera, slam, token) if SLAM in features else None
389
  hands = prepare_hands(selection, camera, hand_actions, token) if HANDS in features else None
390
  if hands and captioner == "flora":
391
  raise ValueError("Run Hand actions and Flora separately so each model has the GPU memory it requires")
392
+ native = hands or camera_slam
393
+ flavor = native["compute"] if native else FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
394
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == flavor)
395
  if hardware.unit_label != "minute":
396
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
 
406
  human_video_parallel,
407
  captioner,
408
  )
409
+ if native:
410
+ costs.update(compute_flavor=flavor, compute_name=COMPUTE[flavor], hand_timing=native["timing"])
411
  return {
412
  "selection": selection,
413
  "features": list(features),
 
420
  "captioner": captioner,
421
  "captioner_profile": dict(FLORA) if captioner == "flora" else None,
422
  "hand_actions": hands,
423
+ "slam": camera_slam,
424
  "human_video": {
425
  # 0 = every subtask.
426
  "segments_per_episode": human_video_segments,
 
517
  ):
518
  if visibility not in ("private", "public"):
519
  raise ValueError("Choose private or public output visibility.")
520
+ camera_slam = plan.get("slam") if plan else None
521
+ hands = (plan.get("hand_actions") or camera_slam) if plan else None
522
+ if camera_slam and camera_slam["release"] != verify_release():
523
+ raise ValueError("SLAM runtime changed; calculate a fresh estimate")
524
  if hands and visibility != "private":
525
  raise ValueError("Hand research outputs must be private")
526
  if hands and hands["runtime"] != pin_runtime(runtime_configuration()):
 
627
  "count": plan["selection"]["count"],
628
  "budget": budget,
629
  "inference_budget": budget - plan["costs"]["cpu_max"],
630
+ "model": "DROID-W + MoGe Small"
631
+ if camera_slam
632
+ else "EgoHand/SOMA + DROID-W + MoGe"
633
+ if set(plan["features"]) in ({HANDS}, {SLAM})
634
  else profile["route"],
635
  "provider_required": uses_provider,
636
  "subtask_models": []
637
+ if set(plan["features"]) in ({HANDS}, {SLAM})
638
  else [FLORA["segmenter_id"], FLORA["model_id"]]
639
  if captioner == "flora"
640
  else [profile["model_id"]],
 
699
  raise ValueError("The hand image is still building; wait for its checks before starting a Job")
700
  image = hands["runtime"]["image"]
701
  command = job_command(space, revision, timeout_minutes, captioner, hands=True)
702
+ if camera_slam:
703
+ command[-1] = command[-1].replace(
704
+ "python /workspace/app/worker.py",
705
+ "export PYTHONPATH=/opt/camera-adapter/src:$PYTHONPATH\npython /workspace/app/slam_job.py",
706
+ )
707
  job = api.run_job(
708
  image=image,
709
  command=command,
subset.py CHANGED
@@ -103,7 +103,7 @@ class EpisodeSubset:
103
  }
104
  )
105
 
106
- def add(self, source_id, table, *, hand_features=None):
107
  """Materialize an annotated episode, retaining all its cameras and existing other styles."""
108
  if any(m["source_episode_index"] == source_id for m in self.mapping):
109
  raise ValueError("Episode was already added to the subset.")
@@ -112,6 +112,10 @@ class EpisodeSubset:
112
  length = int(row["length"])
113
  if len(table) != length or table["frame_index"].to_pylist() != list(range(length)):
114
  raise ValueError("Episode data length or frame order does not match its metadata.")
 
 
 
 
115
  if hand_features:
116
  description = {
117
  "coordinates": "fixed segment-start FLU (X forward, Y left, Z up)",
 
103
  }
104
  )
105
 
106
+ def add(self, source_id, table, *, hand_features=None, extra_features=None):
107
  """Materialize an annotated episode, retaining all its cameras and existing other styles."""
108
  if any(m["source_episode_index"] == source_id for m in self.mapping):
109
  raise ValueError("Episode was already added to the subset.")
 
112
  length = int(row["length"])
113
  if len(table) != length or table["frame_index"].to_pylist() != list(range(length)):
114
  raise ValueError("Episode data length or frame order does not match its metadata.")
115
+ for name, feature in (extra_features or {}).items():
116
+ if name in self.source_info["features"] or name not in table.column_names:
117
+ raise ValueError("New annotations must use a new feature with a matching column")
118
+ self.info["features"][name] = deepcopy(feature)
119
  if hand_features:
120
  description = {
121
  "coordinates": "fixed segment-start FLU (X forward, Y left, Z up)",
test_slam.py ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """SLAM preserves source supervision and rejects false temporal alignment."""
2
+
3
+ import sys
4
+ from pathlib import Path
5
+ from unittest.mock import patch
6
+
7
+ import numpy as np
8
+ import pyarrow as pa
9
+ import pytest
10
+
11
+ sys.path.insert(0, str(Path(__file__).parent))
12
+ from slam_job import POSE, SAMPLED, append_poses
13
+ from slam_pipeline import SLAM, prepare_slam
14
+ from studio import needs_provider
15
+
16
+
17
+ def source():
18
+ return pa.table(
19
+ {
20
+ "timestamp": [0.0, 0.02, 0.04],
21
+ "frame_index": [0, 1, 2],
22
+ "action": [[1.0, 2.0], [3.0, 4.0], [5.0, 6.0]],
23
+ "observation.state": [[0.5], [0.6], [0.7]],
24
+ }
25
+ )
26
+
27
+
28
+ def test_source_actions_and_joint_states_survive(tmp_path):
29
+ table = source()
30
+ path = tmp_path / "camera.npz"
31
+ poses = np.tile(np.eye(4), (3, 1, 1))
32
+ poses[:, 0, 3] = [0, 0.01, 0.02]
33
+ np.savez(path, camera_pose=poses, sampled_indices=[0, 2], timestamp_s=[0, 0.02, 0.04])
34
+ result = append_poses(table, path)
35
+ for name in table.column_names:
36
+ assert result[name].equals(table[name])
37
+ assert result[SAMPLED].to_pylist() == [True, False, True]
38
+ assert np.asarray(result[POSE].to_pylist())[2, 0, 3] == 0.02
39
+
40
+
41
+ def test_bad_timestamps_are_rejected(tmp_path):
42
+ path = tmp_path / "camera.npz"
43
+ np.savez(
44
+ path,
45
+ camera_pose=np.tile(np.eye(4), (3, 1, 1)),
46
+ sampled_indices=[0, 1, 2],
47
+ timestamp_s=[0, 0.03, 0.06],
48
+ )
49
+ with pytest.raises(ValueError, match="timestamps"):
50
+ append_poses(source(), path)
51
+
52
+
53
+ def test_manual_intrinsics_do_not_modify_source():
54
+ selection = {"info": {"fps": 50, "features": {"cam": {"info": {}}}}, "episodes": [{"length": 500}]}
55
+ intrinsics = {"model": "pinhole", "width": 480, "height": 360, "fx": 350, "fy": 350, "cx": 240, "cy": 180}
56
+ with (
57
+ patch("slam_pipeline.verify_release", return_value={}),
58
+ patch("slam_pipeline.prepare_hands", return_value={"calibration": dict(intrinsics)}),
59
+ ):
60
+ result = prepare_slam(
61
+ selection, "cam", {"calibration_mode": "manual", "intrinsics": intrinsics}, False
62
+ )
63
+ assert selection["info"]["features"]["cam"]["info"] == {}
64
+ assert result["sample_fps"] == 50
65
+ assert result["calibration"]["source"] == "user-supplied"
66
+ assert not needs_provider([SLAM])
test_studio.py CHANGED
@@ -25,7 +25,7 @@ from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient
25
  @pytest.mark.parametrize(
26
  "features",
27
  [
28
- [f for f in studio.FEATURES if f != studio.HANDS],
29
  ["Interjections & speech"],
30
  ["Memory"],
31
  ["Visual questions"],
 
25
  @pytest.mark.parametrize(
26
  "features",
27
  [
28
+ [f for f in studio.FEATURES if f not in {studio.HANDS, studio.SLAM}],
29
  ["Interjections & speech"],
30
  ["Memory"],
31
  ["Visual questions"],