Add standalone DROID-W SLAM with camera-frame poses and manual lens intrinsics
Browse files- README.md +23 -0
- flora-worker-image.json +1 -1
- frontend/src/SlamSettings.tsx +31 -0
- frontend/src/main.tsx +23 -5
- hand-worker-image.json +1 -1
- server.py +16 -1
- slam-pipeline.json +18 -0
- slam_job.py +216 -0
- slam_native.py +131 -0
- slam_pipeline.py +73 -0
- studio.py +27 -9
- subset.py +5 -1
- test_slam.py +66 -0
- test_studio.py +1 -1
README.md
CHANGED
|
@@ -41,6 +41,29 @@ tags:
|
|
| 41 |
|
| 42 |

|
| 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 |

|
| 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": "
|
| 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
|
|
|
|
| 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": "
|
| 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=
|
| 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 |
-
|
|
|
|
| 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
|
| 403 |
-
costs.update(compute_flavor=flavor, compute_name=COMPUTE[flavor], hand_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 |
-
|
|
|
|
|
|
|
|
|
|
| 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": "
|
| 620 |
-
if
|
|
|
|
|
|
|
| 621 |
else profile["route"],
|
| 622 |
"provider_required": uses_provider,
|
| 623 |
"subtask_models": []
|
| 624 |
-
if set(plan["features"])
|
| 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
|
| 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"],
|