Add optional independent per-camera subtask timelines
Browse files- README.md +22 -0
- frontend/src/JobsView.tsx +6 -0
- frontend/src/SubtaskViews.tsx +132 -0
- frontend/src/jobs.css +58 -0
- frontend/src/main.tsx +130 -4
- frontend/src/wizard.css +30 -1
- server.py +21 -0
- studio.py +25 -2
- studio_runtime.py +2 -0
- subtask_views.py +85 -0
- test_studio.py +3 -3
- test_subtask_views.py +107 -0
- worker.py +38 -1
README.md
CHANGED
|
@@ -449,3 +449,25 @@ Hand-annotated copies offer **View 3D hands** once an episode is materialized,
|
|
| 449 |
opening synchronized wrists, hand skeletons and camera overlays with `?tab=hands`.
|
| 450 |
Operators can set `HAND_VISUALIZER_URL` to a compatible visualizer deployment;
|
| 451 |
the default is the official LeRobot dataset visualizer.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 449 |
opening synchronized wrists, hand skeletons and camera overlays with `?tab=hands`.
|
| 450 |
Operators can set `HAND_VISUALIZER_URL` to a compatible visualizer deployment;
|
| 451 |
the default is the official LeRobot dataset visualizer.
|
| 452 |
+
|
| 453 |
+
## Per-camera subtasks
|
| 454 |
+
|
| 455 |
+
In the dataset step, select **Separate subtasks per camera** and choose the
|
| 456 |
+
camera views to annotate. The primary camera is always included. Each selected
|
| 457 |
+
view is processed independently from its own images on the original episode
|
| 458 |
+
clock. The estimate includes the additional subtask model calls; the same job
|
| 459 |
+
budget still applies. Plans, memory, hand annotations and generated videos
|
| 460 |
+
continue to use the primary view. Default jobs annotate the primary view only.
|
| 461 |
+
|
| 462 |
+
The output preserves the compatible primary `language_persistent` timeline and
|
| 463 |
+
also writes `annotation_studio/subtasks_by_camera/episode_XXXXXX.parquet`. Each
|
| 464 |
+
row contains `episode_index`, `source_episode_index`, `camera`,
|
| 465 |
+
`start_timestamp`, `end_timestamp` and `subtask`. These are the final validated
|
| 466 |
+
pipeline spans, with no timestamp rounding in the sidecar. Canonical LeRobot
|
| 467 |
+
subtask language rows remain camera-independent; camera-specific labels are
|
| 468 |
+
kept in this explicit sidecar instead of changing that schema.
|
| 469 |
+
|
| 470 |
+
In Jobs, **Preview camera subtask timelines** shows each view on a shared time
|
| 471 |
+
slider, with an episode selector and a Parquet download. Only successfully
|
| 472 |
+
exported episodes appear. This is camera-scoped labeling, not left/right-hand
|
| 473 |
+
action attribution. Existing jobs and completed outputs are not reprocessed.
|
frontend/src/JobsView.tsx
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
| 7 |
RefreshCw,
|
| 8 |
} from "lucide-react";
|
| 9 |
import "./jobs.css";
|
|
|
|
| 10 |
|
| 11 |
export type Job = {
|
| 12 |
id: string;
|
|
@@ -55,6 +56,8 @@ export type Progress = {
|
|
| 55 |
annotation_url?: string | null;
|
| 56 |
hand_visualizer_url?: string | null;
|
| 57 |
output_mode?: string;
|
|
|
|
|
|
|
| 58 |
human_videos?: Record<string, number> | null;
|
| 59 |
};
|
| 60 |
type HumanVideo = {
|
|
@@ -380,6 +383,9 @@ export default function JobsView({
|
|
| 380 |
</div>
|
| 381 |
)}
|
| 382 |
</dl>
|
|
|
|
|
|
|
|
|
|
| 383 |
{!!p?.human_videos?.completed && (
|
| 384 |
<HumanVideos jobId={job.id} />
|
| 385 |
)}
|
|
|
|
| 7 |
RefreshCw,
|
| 8 |
} from "lucide-react";
|
| 9 |
import "./jobs.css";
|
| 10 |
+
import SubtaskViews from "./SubtaskViews";
|
| 11 |
|
| 12 |
export type Job = {
|
| 13 |
id: string;
|
|
|
|
| 56 |
annotation_url?: string | null;
|
| 57 |
hand_visualizer_url?: string | null;
|
| 58 |
output_mode?: string;
|
| 59 |
+
subtask_views?: "primary" | "per-camera";
|
| 60 |
+
subtask_cameras?: string[];
|
| 61 |
human_videos?: Record<string, number> | null;
|
| 62 |
};
|
| 63 |
type HumanVideo = {
|
|
|
|
| 383 |
</div>
|
| 384 |
)}
|
| 385 |
</dl>
|
| 386 |
+
{p?.subtask_views === "per-camera" && !!p.completed && (
|
| 387 |
+
<SubtaskViews jobId={job.id} />
|
| 388 |
+
)}
|
| 389 |
{!!p?.human_videos?.completed && (
|
| 390 |
<HumanVideos jobId={job.id} />
|
| 391 |
)}
|
frontend/src/SubtaskViews.tsx
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState } from "react";
|
| 2 |
+
|
| 3 |
+
type Span = { start: number; end: number; text: string };
|
| 4 |
+
type Episode = {
|
| 5 |
+
source_episode_index: number;
|
| 6 |
+
episode_index: number;
|
| 7 |
+
duration: number;
|
| 8 |
+
cameras: Record<string, Span[]>;
|
| 9 |
+
};
|
| 10 |
+
type Results = { output_repo: string; episodes: Episode[] };
|
| 11 |
+
|
| 12 |
+
export default function SubtaskViews({ jobId }: { jobId: string }) {
|
| 13 |
+
const [results, setResults] = useState<Results | null>(null);
|
| 14 |
+
const [loading, setLoading] = useState(false);
|
| 15 |
+
const [error, setError] = useState("");
|
| 16 |
+
const [episodeIndex, setEpisodeIndex] = useState(0);
|
| 17 |
+
const [time, setTime] = useState(0);
|
| 18 |
+
async function load() {
|
| 19 |
+
setLoading(true);
|
| 20 |
+
setError("");
|
| 21 |
+
try {
|
| 22 |
+
const response = await fetch(
|
| 23 |
+
`/api/jobs/${encodeURIComponent(jobId)}/subtask-views`,
|
| 24 |
+
{ credentials: "same-origin" },
|
| 25 |
+
);
|
| 26 |
+
if (!response.ok)
|
| 27 |
+
throw new Error("No exported camera timelines available yet.");
|
| 28 |
+
setResults(await response.json());
|
| 29 |
+
} catch (reason) {
|
| 30 |
+
setError((reason as Error).message);
|
| 31 |
+
} finally {
|
| 32 |
+
setLoading(false);
|
| 33 |
+
}
|
| 34 |
+
}
|
| 35 |
+
const episode = results?.episodes[episodeIndex];
|
| 36 |
+
return (
|
| 37 |
+
<section className="view-subtasks" aria-label="Camera subtask timelines">
|
| 38 |
+
<button className="text-action" onClick={load} disabled={loading}>
|
| 39 |
+
{loading
|
| 40 |
+
? "Loading camera timelines…"
|
| 41 |
+
: results
|
| 42 |
+
? "Refresh camera timelines"
|
| 43 |
+
: "Preview camera subtask timelines"}
|
| 44 |
+
</button>
|
| 45 |
+
{error && <p role="alert">{error}</p>}
|
| 46 |
+
{results && !episode && <p>No exported camera timelines yet.</p>}
|
| 47 |
+
{episode && (
|
| 48 |
+
<>
|
| 49 |
+
<div className="view-subtask-controls">
|
| 50 |
+
<label>
|
| 51 |
+
Episode{" "}
|
| 52 |
+
<select
|
| 53 |
+
value={episodeIndex}
|
| 54 |
+
onChange={(e) => {
|
| 55 |
+
setEpisodeIndex(Number(e.target.value));
|
| 56 |
+
setTime(0);
|
| 57 |
+
}}
|
| 58 |
+
>
|
| 59 |
+
{results!.episodes.map((ep, i) => (
|
| 60 |
+
<option key={ep.episode_index} value={i}>
|
| 61 |
+
{ep.episode_index} · source {ep.source_episode_index}
|
| 62 |
+
</option>
|
| 63 |
+
))}
|
| 64 |
+
</select>
|
| 65 |
+
</label>
|
| 66 |
+
<span>
|
| 67 |
+
{time.toFixed(2)} / {episode.duration.toFixed(2)} s
|
| 68 |
+
</span>
|
| 69 |
+
<a
|
| 70 |
+
href={`https://huggingface.co/datasets/${results!.output_repo}/resolve/main/annotation_studio/subtasks_by_camera/episode_${String(episode.episode_index).padStart(6, "0")}.parquet`}
|
| 71 |
+
target="_blank"
|
| 72 |
+
rel="noreferrer"
|
| 73 |
+
>
|
| 74 |
+
Download camera spans ↗
|
| 75 |
+
</a>
|
| 76 |
+
</div>
|
| 77 |
+
<input
|
| 78 |
+
aria-label="Camera timeline time"
|
| 79 |
+
type="range"
|
| 80 |
+
min={0}
|
| 81 |
+
max={episode.duration}
|
| 82 |
+
step={0.001}
|
| 83 |
+
value={time}
|
| 84 |
+
onChange={(e) => setTime(Number(e.target.value))}
|
| 85 |
+
/>
|
| 86 |
+
{Object.entries(episode.cameras).map(([camera, spans], index) => {
|
| 87 |
+
const active = spans.find((s) => time >= s.start && time < s.end);
|
| 88 |
+
return (
|
| 89 |
+
<div
|
| 90 |
+
className="view-subtask-row"
|
| 91 |
+
key={`${episode.episode_index}-${camera}`}
|
| 92 |
+
>
|
| 93 |
+
<strong>{camera}</strong>
|
| 94 |
+
<p>{active?.text ?? "No subtask label at this timestamp"}</p>
|
| 95 |
+
<div
|
| 96 |
+
className="view-subtask-lane"
|
| 97 |
+
aria-label={`${camera} timeline`}
|
| 98 |
+
>
|
| 99 |
+
{spans.map((span, i) => (
|
| 100 |
+
<button
|
| 101 |
+
key={i}
|
| 102 |
+
className={span === active ? "active" : ""}
|
| 103 |
+
style={{
|
| 104 |
+
left: `${(100 * span.start) / episode.duration}%`,
|
| 105 |
+
width: `${(100 * (span.end - span.start)) / episode.duration}%`,
|
| 106 |
+
background: [
|
| 107 |
+
"#f4b942",
|
| 108 |
+
"#56b4d3",
|
| 109 |
+
"#75b87a",
|
| 110 |
+
"#b493d0",
|
| 111 |
+
][index % 4],
|
| 112 |
+
}}
|
| 113 |
+
title={`${span.start.toFixed(3)}–${span.end.toFixed(3)} s · ${span.text}`}
|
| 114 |
+
aria-label={`${span.start.toFixed(3)} seconds: ${span.text}`}
|
| 115 |
+
onClick={() => setTime(span.start)}
|
| 116 |
+
/>
|
| 117 |
+
))}
|
| 118 |
+
<i style={{ left: `${(100 * time) / episode.duration}%` }} />
|
| 119 |
+
</div>
|
| 120 |
+
</div>
|
| 121 |
+
);
|
| 122 |
+
})}
|
| 123 |
+
<p className="job-cost-note">
|
| 124 |
+
Independent predictions from each camera's images, on the same
|
| 125 |
+
episode clock. Gaps and exact segment ends are retained. Existing
|
| 126 |
+
jobs are not re-annotated.
|
| 127 |
+
</p>
|
| 128 |
+
</>
|
| 129 |
+
)}
|
| 130 |
+
</section>
|
| 131 |
+
);
|
| 132 |
+
}
|
frontend/src/jobs.css
CHANGED
|
@@ -353,3 +353,61 @@
|
|
| 353 |
grid-template-columns: 1fr;
|
| 354 |
}
|
| 355 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 353 |
grid-template-columns: 1fr;
|
| 354 |
}
|
| 355 |
}
|
| 356 |
+
|
| 357 |
+
.view-subtasks {
|
| 358 |
+
margin-top: 18px;
|
| 359 |
+
padding-top: 14px;
|
| 360 |
+
border-top: 1px solid var(--line-subtle);
|
| 361 |
+
font-size: 12px;
|
| 362 |
+
}
|
| 363 |
+
.view-subtask-controls {
|
| 364 |
+
display: flex;
|
| 365 |
+
flex-wrap: wrap;
|
| 366 |
+
align-items: center;
|
| 367 |
+
gap: 14px;
|
| 368 |
+
margin: 12px 0;
|
| 369 |
+
}
|
| 370 |
+
.view-subtask-controls select {
|
| 371 |
+
margin-left: 6px;
|
| 372 |
+
padding: 5px;
|
| 373 |
+
max-width: 240px;
|
| 374 |
+
}
|
| 375 |
+
.view-subtasks > input[type="range"] {
|
| 376 |
+
width: 100%;
|
| 377 |
+
margin: 8px 0;
|
| 378 |
+
}
|
| 379 |
+
.view-subtask-row {
|
| 380 |
+
margin: 14px 0;
|
| 381 |
+
overflow-wrap: anywhere;
|
| 382 |
+
}
|
| 383 |
+
.view-subtask-row p {
|
| 384 |
+
min-height: 1.5em;
|
| 385 |
+
margin: 5px 0;
|
| 386 |
+
}
|
| 387 |
+
.view-subtask-lane {
|
| 388 |
+
position: relative;
|
| 389 |
+
height: 24px;
|
| 390 |
+
background: var(--line-subtle);
|
| 391 |
+
border-radius: 4px;
|
| 392 |
+
}
|
| 393 |
+
.view-subtask-lane button {
|
| 394 |
+
position: absolute;
|
| 395 |
+
top: 2px;
|
| 396 |
+
bottom: 2px;
|
| 397 |
+
border: 1px solid var(--bg);
|
| 398 |
+
border-radius: 3px;
|
| 399 |
+
opacity: 0.65;
|
| 400 |
+
min-width: 1px;
|
| 401 |
+
padding: 0;
|
| 402 |
+
}
|
| 403 |
+
.view-subtask-lane button.active {
|
| 404 |
+
opacity: 1;
|
| 405 |
+
box-shadow: inset 0 0 0 2px var(--fg);
|
| 406 |
+
}
|
| 407 |
+
.view-subtask-lane i {
|
| 408 |
+
position: absolute;
|
| 409 |
+
top: -3px;
|
| 410 |
+
bottom: -3px;
|
| 411 |
+
border-left: 2px solid var(--fg);
|
| 412 |
+
pointer-events: none;
|
| 413 |
+
}
|
frontend/src/main.tsx
CHANGED
|
@@ -68,6 +68,8 @@ type Options = {
|
|
| 68 |
count: number;
|
| 69 |
features: Feature[];
|
| 70 |
camera: string | null;
|
|
|
|
|
|
|
| 71 |
hz: number;
|
| 72 |
hours: number;
|
| 73 |
mode: string;
|
|
@@ -166,6 +168,8 @@ type Quote = {
|
|
| 166 |
revision: string;
|
| 167 |
cameras: string[];
|
| 168 |
camera: string;
|
|
|
|
|
|
|
| 169 |
private: boolean;
|
| 170 |
first_episode: number;
|
| 171 |
last_episode: number;
|
|
@@ -188,6 +192,8 @@ const defaults: Options = {
|
|
| 188 |
count: 100,
|
| 189 |
features: ["Subtasks"],
|
| 190 |
camera: null,
|
|
|
|
|
|
|
| 191 |
hz: 0.1,
|
| 192 |
hours: 4,
|
| 193 |
mode: COPY,
|
|
@@ -314,6 +320,14 @@ function initial(): Options {
|
|
| 314 |
: safe.human_video_parallel,
|
| 315 |
captioner: saved.captioner === "flora" ? "flora" : "selected-vlm",
|
| 316 |
camera: typeof saved.camera === "string" ? saved.camera : null,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
slam: {
|
| 318 |
compute: "rtx-pro-6000",
|
| 319 |
output_fps: slam.output_fps === 30 ? 30 : 50,
|
|
@@ -532,6 +546,13 @@ function App() {
|
|
| 532 |
old.camera && result.cameras.includes(old.camera)
|
| 533 |
? old.camera
|
| 534 |
: result.cameras[0],
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 535 |
}));
|
| 536 |
// Discovery also runs after a retry or login; require a fresh quote in case views changed.
|
| 537 |
generation.current++;
|
|
@@ -657,6 +678,7 @@ function App() {
|
|
| 657 |
setCameras([]);
|
| 658 |
setCameraError("");
|
| 659 |
patch.camera = null;
|
|
|
|
| 660 |
}
|
| 661 |
if ("dataset" in patch || "start" in patch || "count" in patch)
|
| 662 |
setMetadata(null);
|
|
@@ -671,9 +693,16 @@ function App() {
|
|
| 671 |
let features = on
|
| 672 |
? [...options.features, name]
|
| 673 |
: options.features.filter((x) => x !== name);
|
|
|
|
|
|
|
| 674 |
if (on && name === SLAM) {
|
| 675 |
setVisibility("private");
|
| 676 |
-
change({
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
return;
|
| 678 |
}
|
| 679 |
if (on) features = features.filter((x) => x !== SLAM);
|
|
@@ -695,7 +724,19 @@ function App() {
|
|
| 695 |
try {
|
| 696 |
const q = await api<Quote>("/api/estimate", {
|
| 697 |
method: "POST",
|
| 698 |
-
body: JSON.stringify(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 699 |
});
|
| 700 |
if (gen === generation.current) {
|
| 701 |
setQuote(q);
|
|
@@ -1122,8 +1163,77 @@ function App() {
|
|
| 1122 |
) : null}
|
| 1123 |
</div>
|
| 1124 |
<p className="field-help" id="camera-help">
|
| 1125 |
-
|
|
|
|
| 1126 |
</p>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1127 |
</section>
|
| 1128 |
)}
|
| 1129 |
{step === 1 && !usesSlam && (
|
|
@@ -1647,6 +1757,14 @@ function App() {
|
|
| 1647 |
<span>Camera</span>
|
| 1648 |
<strong>{options.camera}</strong>
|
| 1649 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1650 |
<div>
|
| 1651 |
<span>Annotations</span>
|
| 1652 |
<strong>{options.features.join(", ")}</strong>
|
|
@@ -2265,9 +2383,17 @@ function App() {
|
|
| 2265 |
<dd>{quote && num(quote.episodes)}</dd>
|
| 2266 |
</div>
|
| 2267 |
<div>
|
| 2268 |
-
<dt>
|
| 2269 |
<dd>{quote?.camera}</dd>
|
| 2270 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2271 |
<div>
|
| 2272 |
<dt>Annotations</dt>
|
| 2273 |
<dd>{options.features.join(", ")}</dd>
|
|
|
|
| 68 |
count: number;
|
| 69 |
features: Feature[];
|
| 70 |
camera: string | null;
|
| 71 |
+
subtask_views: "primary" | "per-camera";
|
| 72 |
+
subtask_cameras: string[];
|
| 73 |
hz: number;
|
| 74 |
hours: number;
|
| 75 |
mode: string;
|
|
|
|
| 168 |
revision: string;
|
| 169 |
cameras: string[];
|
| 170 |
camera: string;
|
| 171 |
+
subtask_views?: "primary" | "per-camera";
|
| 172 |
+
subtask_cameras?: string[];
|
| 173 |
private: boolean;
|
| 174 |
first_episode: number;
|
| 175 |
last_episode: number;
|
|
|
|
| 192 |
count: 100,
|
| 193 |
features: ["Subtasks"],
|
| 194 |
camera: null,
|
| 195 |
+
subtask_views: "primary",
|
| 196 |
+
subtask_cameras: [],
|
| 197 |
hz: 0.1,
|
| 198 |
hours: 4,
|
| 199 |
mode: COPY,
|
|
|
|
| 320 |
: safe.human_video_parallel,
|
| 321 |
captioner: saved.captioner === "flora" ? "flora" : "selected-vlm",
|
| 322 |
camera: typeof saved.camera === "string" ? saved.camera : null,
|
| 323 |
+
subtask_views:
|
| 324 |
+
saved.subtask_views === "per-camera" &&
|
| 325 |
+
safe.features.includes("Subtasks")
|
| 326 |
+
? "per-camera"
|
| 327 |
+
: "primary",
|
| 328 |
+
subtask_cameras: Array.isArray(saved.subtask_cameras)
|
| 329 |
+
? saved.subtask_cameras.filter((c: unknown) => typeof c === "string")
|
| 330 |
+
: [],
|
| 331 |
slam: {
|
| 332 |
compute: "rtx-pro-6000",
|
| 333 |
output_fps: slam.output_fps === 30 ? 30 : 50,
|
|
|
|
| 546 |
old.camera && result.cameras.includes(old.camera)
|
| 547 |
? old.camera
|
| 548 |
: result.cameras[0],
|
| 549 |
+
subtask_cameras:
|
| 550 |
+
old.subtask_views === "per-camera"
|
| 551 |
+
? old.subtask_cameras.filter((c) => result.cameras.includes(c))
|
| 552 |
+
.length
|
| 553 |
+
? old.subtask_cameras.filter((c) => result.cameras.includes(c))
|
| 554 |
+
: result.cameras
|
| 555 |
+
: [],
|
| 556 |
}));
|
| 557 |
// Discovery also runs after a retry or login; require a fresh quote in case views changed.
|
| 558 |
generation.current++;
|
|
|
|
| 678 |
setCameras([]);
|
| 679 |
setCameraError("");
|
| 680 |
patch.camera = null;
|
| 681 |
+
patch.subtask_cameras = [];
|
| 682 |
}
|
| 683 |
if ("dataset" in patch || "start" in patch || "count" in patch)
|
| 684 |
setMetadata(null);
|
|
|
|
| 693 |
let features = on
|
| 694 |
? [...options.features, name]
|
| 695 |
: options.features.filter((x) => x !== name);
|
| 696 |
+
if (!on && name === "Subtasks")
|
| 697 |
+
change({ subtask_views: "primary", subtask_cameras: [] });
|
| 698 |
if (on && name === SLAM) {
|
| 699 |
setVisibility("private");
|
| 700 |
+
change({
|
| 701 |
+
features: [SLAM],
|
| 702 |
+
captioner: "selected-vlm",
|
| 703 |
+
subtask_views: "primary",
|
| 704 |
+
subtask_cameras: [],
|
| 705 |
+
});
|
| 706 |
return;
|
| 707 |
}
|
| 708 |
if (on) features = features.filter((x) => x !== SLAM);
|
|
|
|
| 724 |
try {
|
| 725 |
const q = await api<Quote>("/api/estimate", {
|
| 726 |
method: "POST",
|
| 727 |
+
body: JSON.stringify({
|
| 728 |
+
...options,
|
| 729 |
+
subtask_cameras:
|
| 730 |
+
options.subtask_views === "per-camera"
|
| 731 |
+
? [
|
| 732 |
+
...new Set(
|
| 733 |
+
[options.camera, ...options.subtask_cameras].filter(
|
| 734 |
+
(c): c is string => !!c && cameras.includes(c),
|
| 735 |
+
),
|
| 736 |
+
),
|
| 737 |
+
]
|
| 738 |
+
: [],
|
| 739 |
+
}),
|
| 740 |
});
|
| 741 |
if (gen === generation.current) {
|
| 742 |
setQuote(q);
|
|
|
|
| 1163 |
) : null}
|
| 1164 |
</div>
|
| 1165 |
<p className="field-help" id="camera-help">
|
| 1166 |
+
Primary view for the existing timeline, plans, memory and
|
| 1167 |
+
generated videos.
|
| 1168 |
</p>
|
| 1169 |
+
{options.features.includes("Subtasks") && (
|
| 1170 |
+
<fieldset className="subtask-view-settings">
|
| 1171 |
+
<legend>Subtask camera views</legend>
|
| 1172 |
+
<label className="field-label" htmlFor="subtask-views">
|
| 1173 |
+
Output timelines
|
| 1174 |
+
</label>
|
| 1175 |
+
<div className="select-wrap">
|
| 1176 |
+
<Video size={16} />
|
| 1177 |
+
<select
|
| 1178 |
+
id="subtask-views"
|
| 1179 |
+
value={options.subtask_views}
|
| 1180 |
+
onChange={(e) =>
|
| 1181 |
+
change({
|
| 1182 |
+
subtask_views: e.target
|
| 1183 |
+
.value as Options["subtask_views"],
|
| 1184 |
+
subtask_cameras:
|
| 1185 |
+
e.target.value === "per-camera" ? cameras : [],
|
| 1186 |
+
})
|
| 1187 |
+
}
|
| 1188 |
+
>
|
| 1189 |
+
<option value="primary">Primary camera only</option>
|
| 1190 |
+
<option value="per-camera">
|
| 1191 |
+
Separate timeline per camera view
|
| 1192 |
+
</option>
|
| 1193 |
+
</select>
|
| 1194 |
+
<ChevronDown size={15} />
|
| 1195 |
+
</div>
|
| 1196 |
+
{options.subtask_views === "per-camera" && (
|
| 1197 |
+
<>
|
| 1198 |
+
<div className="subtask-camera-options">
|
| 1199 |
+
{cameras.map((camera) => (
|
| 1200 |
+
<label key={camera}>
|
| 1201 |
+
<input
|
| 1202 |
+
type="checkbox"
|
| 1203 |
+
checked={
|
| 1204 |
+
camera === options.camera ||
|
| 1205 |
+
options.subtask_cameras.includes(camera)
|
| 1206 |
+
}
|
| 1207 |
+
disabled={camera === options.camera}
|
| 1208 |
+
onChange={(e) =>
|
| 1209 |
+
change({
|
| 1210 |
+
subtask_cameras: e.target.checked
|
| 1211 |
+
? [...options.subtask_cameras, camera]
|
| 1212 |
+
: options.subtask_cameras.filter(
|
| 1213 |
+
(c) => c !== camera,
|
| 1214 |
+
),
|
| 1215 |
+
})
|
| 1216 |
+
}
|
| 1217 |
+
/>
|
| 1218 |
+
<span>
|
| 1219 |
+
{camera}
|
| 1220 |
+
{camera === options.camera
|
| 1221 |
+
? " (primary)"
|
| 1222 |
+
: ""}
|
| 1223 |
+
</span>
|
| 1224 |
+
</label>
|
| 1225 |
+
))}
|
| 1226 |
+
</div>
|
| 1227 |
+
<p className="field-help">
|
| 1228 |
+
Each checked view is annotated from its own images
|
| 1229 |
+
and keeps its own timestamped timeline. The estimate
|
| 1230 |
+
includes all checked views. Hands and generated
|
| 1231 |
+
videos still use the primary camera.
|
| 1232 |
+
</p>
|
| 1233 |
+
</>
|
| 1234 |
+
)}
|
| 1235 |
+
</fieldset>
|
| 1236 |
+
)}
|
| 1237 |
</section>
|
| 1238 |
)}
|
| 1239 |
{step === 1 && !usesSlam && (
|
|
|
|
| 1757 |
<span>Camera</span>
|
| 1758 |
<strong>{options.camera}</strong>
|
| 1759 |
</div>
|
| 1760 |
+
<div>
|
| 1761 |
+
<span>Subtask views</span>
|
| 1762 |
+
<strong>
|
| 1763 |
+
{quote?.subtask_views === "per-camera"
|
| 1764 |
+
? `${quote.subtask_cameras?.length} independent camera timelines per episode`
|
| 1765 |
+
: "Primary camera only"}
|
| 1766 |
+
</strong>
|
| 1767 |
+
</div>
|
| 1768 |
<div>
|
| 1769 |
<span>Annotations</span>
|
| 1770 |
<strong>{options.features.join(", ")}</strong>
|
|
|
|
| 2383 |
<dd>{quote && num(quote.episodes)}</dd>
|
| 2384 |
</div>
|
| 2385 |
<div>
|
| 2386 |
+
<dt>Primary camera</dt>
|
| 2387 |
<dd>{quote?.camera}</dd>
|
| 2388 |
</div>
|
| 2389 |
+
<div>
|
| 2390 |
+
<dt>Subtask camera views</dt>
|
| 2391 |
+
<dd>
|
| 2392 |
+
{quote?.subtask_views === "per-camera"
|
| 2393 |
+
? quote.subtask_cameras?.join(", ")
|
| 2394 |
+
: "Primary camera only"}
|
| 2395 |
+
</dd>
|
| 2396 |
+
</div>
|
| 2397 |
<div>
|
| 2398 |
<dt>Annotations</dt>
|
| 2399 |
<dd>{options.features.join(", ")}</dd>
|
frontend/src/wizard.css
CHANGED
|
@@ -411,7 +411,9 @@
|
|
| 411 |
gap: 6px;
|
| 412 |
}
|
| 413 |
@media (max-width: 480px) {
|
| 414 |
-
.slam-intrinsics-grid {
|
|
|
|
|
|
|
| 415 |
}
|
| 416 |
.visibility-options {
|
| 417 |
display: grid;
|
|
@@ -573,3 +575,30 @@
|
|
| 573 |
.hand-pipeline-steps li + li {
|
| 574 |
margin-top: 12px;
|
| 575 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 411 |
gap: 6px;
|
| 412 |
}
|
| 413 |
@media (max-width: 480px) {
|
| 414 |
+
.slam-intrinsics-grid {
|
| 415 |
+
grid-template-columns: 1fr;
|
| 416 |
+
}
|
| 417 |
}
|
| 418 |
.visibility-options {
|
| 419 |
display: grid;
|
|
|
|
| 575 |
.hand-pipeline-steps li + li {
|
| 576 |
margin-top: 12px;
|
| 577 |
}
|
| 578 |
+
|
| 579 |
+
.subtask-view-settings {
|
| 580 |
+
margin: 20px 0 0;
|
| 581 |
+
padding: 16px;
|
| 582 |
+
border: 1px solid var(--line-subtle);
|
| 583 |
+
border-radius: 10px;
|
| 584 |
+
}
|
| 585 |
+
.subtask-view-settings legend {
|
| 586 |
+
padding: 0 6px;
|
| 587 |
+
font-weight: 600;
|
| 588 |
+
font-size: 13px;
|
| 589 |
+
}
|
| 590 |
+
.subtask-camera-options {
|
| 591 |
+
display: grid;
|
| 592 |
+
gap: 8px;
|
| 593 |
+
margin: 12px 0;
|
| 594 |
+
}
|
| 595 |
+
.subtask-camera-options label {
|
| 596 |
+
display: flex;
|
| 597 |
+
align-items: flex-start;
|
| 598 |
+
gap: 8px;
|
| 599 |
+
font-size: 12px;
|
| 600 |
+
overflow-wrap: anywhere;
|
| 601 |
+
}
|
| 602 |
+
.subtask-camera-options input {
|
| 603 |
+
flex: 0 0 auto;
|
| 604 |
+
}
|
server.py
CHANGED
|
@@ -27,6 +27,8 @@ from reporting import annotation_url, episode_progress, hand_visualizer_url, usa
|
|
| 27 |
from slam_pipeline import catalog as slam_catalog
|
| 28 |
from studio import COPY, FEATURES, launch, prepare, read_cameras
|
| 29 |
|
|
|
|
|
|
|
| 30 |
app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
|
| 31 |
BASE = Path(__file__).parent
|
| 32 |
HOST = os.environ.get("SPACE_HOST")
|
|
@@ -65,6 +67,8 @@ class EstimateInput(DatasetInput):
|
|
| 65 |
hand_actions: HandActionInput | None = None
|
| 66 |
slam: SlamInput | None = None
|
| 67 |
camera: str | None = Field(default=None, max_length=200)
|
|
|
|
|
|
|
| 68 |
hz: float = 0.1
|
| 69 |
hours: int = 4
|
| 70 |
mode: Literal["Annotated dataset copy"] = COPY
|
|
@@ -311,6 +315,8 @@ def calculate(options: EstimateInput, request: Request):
|
|
| 311 |
captioner=options.captioner,
|
| 312 |
hand_actions=options.hand_actions.model_dump() if options.hand_actions else None,
|
| 313 |
slam=options.slam.model_dump() if options.slam else None,
|
|
|
|
|
|
|
| 314 |
)
|
| 315 |
except Exception as exc:
|
| 316 |
raise HTTPException(400, safe_error(exc)) from None
|
|
@@ -336,6 +342,8 @@ def calculate(options: EstimateInput, request: Request):
|
|
| 336 |
"model_profile": plan["model_profile"],
|
| 337 |
"captioner": plan.get("captioner", "selected-vlm"),
|
| 338 |
"camera": plan["camera"],
|
|
|
|
|
|
|
| 339 |
"cameras": s["cameras"],
|
| 340 |
"episodes": len(s["episodes"]),
|
| 341 |
"total_episodes": s["total_episodes"],
|
|
@@ -501,12 +509,25 @@ def progress(job_id: str, request: Request):
|
|
| 501 |
"output_mode": report.get("manifest", {}).get("mode"),
|
| 502 |
"model": report.get("manifest", {}).get("model"),
|
| 503 |
"captioner": report.get("manifest", {}).get("captioner"),
|
|
|
|
|
|
|
| 504 |
"human_videos": videos if (report.get("manifest", {}).get("human_video")) else None,
|
| 505 |
}
|
| 506 |
except Exception as exc:
|
| 507 |
raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
|
| 508 |
|
| 509 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 510 |
@app.get("/api/jobs/{job_id}/human-videos")
|
| 511 |
def human_videos(job_id: str, request: Request):
|
| 512 |
current = session(request, required=True)
|
|
|
|
| 27 |
from slam_pipeline import catalog as slam_catalog
|
| 28 |
from studio import COPY, FEATURES, launch, prepare, read_cameras
|
| 29 |
|
| 30 |
+
from subtask_views import preview_timelines
|
| 31 |
+
|
| 32 |
app = FastAPI(title="LeRobot Annotation Studio", docs_url=None, redoc_url=None, openapi_url=None)
|
| 33 |
BASE = Path(__file__).parent
|
| 34 |
HOST = os.environ.get("SPACE_HOST")
|
|
|
|
| 67 |
hand_actions: HandActionInput | None = None
|
| 68 |
slam: SlamInput | None = None
|
| 69 |
camera: str | None = Field(default=None, max_length=200)
|
| 70 |
+
subtask_views: Literal["primary", "per-camera"] = "primary"
|
| 71 |
+
subtask_cameras: list[str] = Field(default_factory=list, max_length=32)
|
| 72 |
hz: float = 0.1
|
| 73 |
hours: int = 4
|
| 74 |
mode: Literal["Annotated dataset copy"] = COPY
|
|
|
|
| 315 |
captioner=options.captioner,
|
| 316 |
hand_actions=options.hand_actions.model_dump() if options.hand_actions else None,
|
| 317 |
slam=options.slam.model_dump() if options.slam else None,
|
| 318 |
+
subtask_views=options.subtask_views,
|
| 319 |
+
subtask_cameras=options.subtask_cameras,
|
| 320 |
)
|
| 321 |
except Exception as exc:
|
| 322 |
raise HTTPException(400, safe_error(exc)) from None
|
|
|
|
| 342 |
"model_profile": plan["model_profile"],
|
| 343 |
"captioner": plan.get("captioner", "selected-vlm"),
|
| 344 |
"camera": plan["camera"],
|
| 345 |
+
"subtask_views": plan.get("subtask_views", "primary"),
|
| 346 |
+
"subtask_cameras": plan.get("subtask_cameras", [plan["camera"]]),
|
| 347 |
"cameras": s["cameras"],
|
| 348 |
"episodes": len(s["episodes"]),
|
| 349 |
"total_episodes": s["total_episodes"],
|
|
|
|
| 509 |
"output_mode": report.get("manifest", {}).get("mode"),
|
| 510 |
"model": report.get("manifest", {}).get("model"),
|
| 511 |
"captioner": report.get("manifest", {}).get("captioner"),
|
| 512 |
+
"subtask_views": report.get("manifest", {}).get("subtask_views", "primary"),
|
| 513 |
+
"subtask_cameras": report.get("manifest", {}).get("subtask_cameras", []),
|
| 514 |
"human_videos": videos if (report.get("manifest", {}).get("human_video")) else None,
|
| 515 |
}
|
| 516 |
except Exception as exc:
|
| 517 |
raise HTTPException(400, "No saved progress yet. The job may still be starting.") from exc
|
| 518 |
|
| 519 |
|
| 520 |
+
@app.get("/api/jobs/{job_id}/subtask-views")
|
| 521 |
+
def subtask_view_results(job_id: str, request: Request):
|
| 522 |
+
current = session(request, required=True)
|
| 523 |
+
try:
|
| 524 |
+
_, output = studio_output(job_id, current)
|
| 525 |
+
report = json.loads(read_output_file(output, "annotation_studio/run.json", current["token"]))
|
| 526 |
+
return {**preview_timelines(report), "output_repo": output}
|
| 527 |
+
except Exception as exc:
|
| 528 |
+
raise HTTPException(400, "No exported per-camera subtask timelines available yet.") from exc
|
| 529 |
+
|
| 530 |
+
|
| 531 |
@app.get("/api/jobs/{job_id}/human-videos")
|
| 532 |
def human_videos(job_id: str, request: Request):
|
| 533 |
current = session(request, required=True)
|
studio.py
CHANGED
|
@@ -30,6 +30,8 @@ from model_catalog import (
|
|
| 30 |
from prompt_config import TEMPLATES, validate_prompts
|
| 31 |
from slam_pipeline import SLAM, SLAM_COMPUTE, prepare_slam, verify_release
|
| 32 |
|
|
|
|
|
|
|
| 33 |
MODEL = model_profile()["route"]
|
| 34 |
PROVIDER = model_profile()["provider"]
|
| 35 |
FEATURES = {
|
|
@@ -193,6 +195,7 @@ def estimate(
|
|
| 193 |
human_video_parallel=None,
|
| 194 |
captioner="selected-vlm",
|
| 195 |
human_video_model="minimax-h3",
|
|
|
|
| 196 |
):
|
| 197 |
if captioner not in {"selected-vlm", "flora"}:
|
| 198 |
raise ValueError("Choose a supported subtask captioner.")
|
|
@@ -224,10 +227,14 @@ def estimate(
|
|
| 224 |
max(1, math.ceil(r["length"] / selection["info"]["fps"] / 600)) for r in selection["episodes"]
|
| 225 |
)
|
| 226 |
# Token estimates, not measured usage. Plans are deterministic and add no model calls.
|
|
|
|
|
|
|
| 227 |
provider_subtasks = needs_subtasks and captioner != "flora"
|
| 228 |
inputs = (seconds / 10 * 1500 + windows * 1500) if provider_subtasks else 0
|
| 229 |
outputs = (seconds / 5 * 55 + windows * 120) if provider_subtasks else 0
|
| 230 |
-
|
|
|
|
|
|
|
| 231 |
if "Memory" in features:
|
| 232 |
memories = max(0, seconds / 5 - n)
|
| 233 |
inputs += memories * 1800
|
|
@@ -273,7 +280,7 @@ def estimate(
|
|
| 273 |
calls += plan_calls
|
| 274 |
# Longer custom templates add input cost. Generation length remains uncertain.
|
| 275 |
prompt_calls = {
|
| 276 |
-
"plan_subtasks": windows,
|
| 277 |
"plan_memory": max(0, seconds / 5 - n),
|
| 278 |
"plan_task_rephrasings": n,
|
| 279 |
"interjections_initial_speech": n,
|
|
@@ -304,6 +311,8 @@ def estimate(
|
|
| 304 |
"compute_name": "H200 GPU" if captioner == "flora" else "CPU",
|
| 305 |
"helper_required": captioner == "flora" and needs_provider(features, prompts, captioner),
|
| 306 |
"calls": math.ceil(calls),
|
|
|
|
|
|
|
| 307 |
"custom_prompts": len(prompts),
|
| 308 |
"hours": hours,
|
| 309 |
"input_rate": profile["input_rate"],
|
|
@@ -331,6 +340,8 @@ def fingerprint(
|
|
| 331 |
human_video_parallel=None,
|
| 332 |
captioner="selected-vlm",
|
| 333 |
human_video_model="minimax-h3",
|
|
|
|
|
|
|
| 334 |
):
|
| 335 |
return hashlib.sha256(
|
| 336 |
json.dumps(
|
|
@@ -348,6 +359,8 @@ def fingerprint(
|
|
| 348 |
human_video_parallel,
|
| 349 |
captioner,
|
| 350 |
human_video_model,
|
|
|
|
|
|
|
| 351 |
],
|
| 352 |
sort_keys=True,
|
| 353 |
).encode()
|
|
@@ -378,6 +391,8 @@ def prepare(
|
|
| 378 |
hand_actions=None,
|
| 379 |
slam=None,
|
| 380 |
human_video_model="minimax-h3",
|
|
|
|
|
|
|
| 381 |
):
|
| 382 |
profile = model_profile(model_key)
|
| 383 |
prompts = validate_prompts(prompts or {}, features)
|
|
@@ -396,6 +411,9 @@ def prepare(
|
|
| 396 |
camera = camera or selection["cameras"][0]
|
| 397 |
if camera not in selection["cameras"]:
|
| 398 |
raise ValueError("Choose a camera in this dataset.")
|
|
|
|
|
|
|
|
|
|
| 399 |
if captioner not in {"selected-vlm", "flora"}:
|
| 400 |
raise ValueError("Choose a supported subtask captioner.")
|
| 401 |
if SLAM in features and (set(features) != {SLAM} or captioner == "flora"):
|
|
@@ -423,6 +441,7 @@ def prepare(
|
|
| 423 |
human_video_parallel,
|
| 424 |
captioner,
|
| 425 |
human_video_model,
|
|
|
|
| 426 |
)
|
| 427 |
if pika:
|
| 428 |
costs.update(compute_flavor="a100-large", compute_name="A100 GPU")
|
|
@@ -436,6 +455,8 @@ def prepare(
|
|
| 436 |
"selection": selection,
|
| 437 |
"features": list(features),
|
| 438 |
"camera": camera,
|
|
|
|
|
|
|
| 439 |
"vqa_hz": vqa_hz,
|
| 440 |
"mode": mode,
|
| 441 |
"prompts": prompts,
|
|
@@ -469,6 +490,8 @@ def prepare(
|
|
| 469 |
human_video_parallel,
|
| 470 |
captioner,
|
| 471 |
human_video_model,
|
|
|
|
|
|
|
| 472 |
),
|
| 473 |
}
|
| 474 |
|
|
|
|
| 30 |
from prompt_config import TEMPLATES, validate_prompts
|
| 31 |
from slam_pipeline import SLAM, SLAM_COMPUTE, prepare_slam, verify_release
|
| 32 |
|
| 33 |
+
from subtask_views import select_subtask_cameras
|
| 34 |
+
|
| 35 |
MODEL = model_profile()["route"]
|
| 36 |
PROVIDER = model_profile()["provider"]
|
| 37 |
FEATURES = {
|
|
|
|
| 195 |
human_video_parallel=None,
|
| 196 |
captioner="selected-vlm",
|
| 197 |
human_video_model="minimax-h3",
|
| 198 |
+
subtask_camera_count=1,
|
| 199 |
):
|
| 200 |
if captioner not in {"selected-vlm", "flora"}:
|
| 201 |
raise ValueError("Choose a supported subtask captioner.")
|
|
|
|
| 227 |
max(1, math.ceil(r["length"] / selection["info"]["fps"] / 600)) for r in selection["episodes"]
|
| 228 |
)
|
| 229 |
# Token estimates, not measured usage. Plans are deterministic and add no model calls.
|
| 230 |
+
if type(subtask_camera_count) is not int or not 1 <= subtask_camera_count <= 32:
|
| 231 |
+
raise ValueError("Choose between 1 and 32 subtask camera views.")
|
| 232 |
provider_subtasks = needs_subtasks and captioner != "flora"
|
| 233 |
inputs = (seconds / 10 * 1500 + windows * 1500) if provider_subtasks else 0
|
| 234 |
outputs = (seconds / 5 * 55 + windows * 120) if provider_subtasks else 0
|
| 235 |
+
inputs *= subtask_camera_count
|
| 236 |
+
outputs *= subtask_camera_count
|
| 237 |
+
calls = windows * subtask_camera_count if provider_subtasks else 0
|
| 238 |
if "Memory" in features:
|
| 239 |
memories = max(0, seconds / 5 - n)
|
| 240 |
inputs += memories * 1800
|
|
|
|
| 280 |
calls += plan_calls
|
| 281 |
# Longer custom templates add input cost. Generation length remains uncertain.
|
| 282 |
prompt_calls = {
|
| 283 |
+
"plan_subtasks": windows * subtask_camera_count,
|
| 284 |
"plan_memory": max(0, seconds / 5 - n),
|
| 285 |
"plan_task_rephrasings": n,
|
| 286 |
"interjections_initial_speech": n,
|
|
|
|
| 311 |
"compute_name": "H200 GPU" if captioner == "flora" else "CPU",
|
| 312 |
"helper_required": captioner == "flora" and needs_provider(features, prompts, captioner),
|
| 313 |
"calls": math.ceil(calls),
|
| 314 |
+
"subtask_camera_count": subtask_camera_count,
|
| 315 |
+
"subtask_video_hours": seconds * subtask_camera_count / 3600,
|
| 316 |
"custom_prompts": len(prompts),
|
| 317 |
"hours": hours,
|
| 318 |
"input_rate": profile["input_rate"],
|
|
|
|
| 340 |
human_video_parallel=None,
|
| 341 |
captioner="selected-vlm",
|
| 342 |
human_video_model="minimax-h3",
|
| 343 |
+
subtask_views="primary",
|
| 344 |
+
subtask_cameras=None,
|
| 345 |
):
|
| 346 |
return hashlib.sha256(
|
| 347 |
json.dumps(
|
|
|
|
| 359 |
human_video_parallel,
|
| 360 |
captioner,
|
| 361 |
human_video_model,
|
| 362 |
+
subtask_views,
|
| 363 |
+
subtask_cameras,
|
| 364 |
],
|
| 365 |
sort_keys=True,
|
| 366 |
).encode()
|
|
|
|
| 391 |
hand_actions=None,
|
| 392 |
slam=None,
|
| 393 |
human_video_model="minimax-h3",
|
| 394 |
+
subtask_views="primary",
|
| 395 |
+
subtask_cameras=None,
|
| 396 |
):
|
| 397 |
profile = model_profile(model_key)
|
| 398 |
prompts = validate_prompts(prompts or {}, features)
|
|
|
|
| 411 |
camera = camera or selection["cameras"][0]
|
| 412 |
if camera not in selection["cameras"]:
|
| 413 |
raise ValueError("Choose a camera in this dataset.")
|
| 414 |
+
views = select_subtask_cameras(camera, selection["cameras"], subtask_views, subtask_cameras)
|
| 415 |
+
if subtask_views == "per-camera" and "Subtasks" not in features:
|
| 416 |
+
raise ValueError("Select Subtasks to export per-camera timelines.")
|
| 417 |
if captioner not in {"selected-vlm", "flora"}:
|
| 418 |
raise ValueError("Choose a supported subtask captioner.")
|
| 419 |
if SLAM in features and (set(features) != {SLAM} or captioner == "flora"):
|
|
|
|
| 441 |
human_video_parallel,
|
| 442 |
captioner,
|
| 443 |
human_video_model,
|
| 444 |
+
subtask_camera_count=len(views),
|
| 445 |
)
|
| 446 |
if pika:
|
| 447 |
costs.update(compute_flavor="a100-large", compute_name="A100 GPU")
|
|
|
|
| 455 |
"selection": selection,
|
| 456 |
"features": list(features),
|
| 457 |
"camera": camera,
|
| 458 |
+
"subtask_views": subtask_views,
|
| 459 |
+
"subtask_cameras": views,
|
| 460 |
"vqa_hz": vqa_hz,
|
| 461 |
"mode": mode,
|
| 462 |
"prompts": prompts,
|
|
|
|
| 490 |
human_video_parallel,
|
| 491 |
captioner,
|
| 492 |
human_video_model,
|
| 493 |
+
subtask_views,
|
| 494 |
+
views,
|
| 495 |
),
|
| 496 |
}
|
| 497 |
|
studio_runtime.py
CHANGED
|
@@ -272,6 +272,8 @@ def run_episode(record, row, info, root, manifest, provider, staging_dir, media=
|
|
| 272 |
for key in (
|
| 273 |
"features",
|
| 274 |
"camera",
|
|
|
|
|
|
|
| 275 |
"vqa_hz",
|
| 276 |
"prompts",
|
| 277 |
"model",
|
|
|
|
| 272 |
for key in (
|
| 273 |
"features",
|
| 274 |
"camera",
|
| 275 |
+
"subtask_views",
|
| 276 |
+
"subtask_cameras",
|
| 277 |
"vqa_hz",
|
| 278 |
"prompts",
|
| 279 |
"model",
|
subtask_views.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Camera-scoped subtask outputs alongside the compatible primary language timeline."""
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import math
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import pyarrow as pa
|
| 9 |
+
import pyarrow.parquet as pq
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
VIEW_INSTRUCTIONS = """
|
| 13 |
+
Annotate only the activity supported by this camera's images. This is one camera
|
| 14 |
+
view, not a combined multi-camera observation. Do not infer hidden actions from
|
| 15 |
+
the overall task or invent activity visible only in another camera. If the view
|
| 16 |
+
does not show an action, explicitly describe the visible state or that the action
|
| 17 |
+
is out of view. Keep the episode's original clock and the required JSON schema.
|
| 18 |
+
"""
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def select_subtask_cameras(
|
| 22 |
+
primary: str, available: list[str], mode: str = "primary", cameras: list[str] | None = None,
|
| 23 |
+
) -> list[str]:
|
| 24 |
+
if mode not in {"primary", "per-camera"}:
|
| 25 |
+
raise ValueError("Choose primary-camera or per-camera subtasks.")
|
| 26 |
+
if primary not in available:
|
| 27 |
+
raise ValueError("Choose a primary camera in this dataset.")
|
| 28 |
+
if mode == "primary":
|
| 29 |
+
return [primary]
|
| 30 |
+
chosen = list(available) if not cameras else cameras
|
| 31 |
+
if not isinstance(chosen, list) or not all(isinstance(c, str) for c in chosen):
|
| 32 |
+
raise ValueError("Subtask cameras must be a list of camera names.")
|
| 33 |
+
if len(chosen) + int(primary not in chosen) > 32 or len(set(chosen)) != len(chosen):
|
| 34 |
+
raise ValueError("Choose at most 32 distinct subtask cameras.")
|
| 35 |
+
if any(c not in available for c in chosen):
|
| 36 |
+
raise ValueError("A selected subtask camera is not in this dataset.")
|
| 37 |
+
# The primary timeline also feeds plans, memory and generated videos.
|
| 38 |
+
return [primary, *[c for c in chosen if c != primary]]
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def write_view_subtasks(
|
| 42 |
+
report: dict[str, Any], output_episode: int, root: Path, manifest: dict[str, Any],
|
| 43 |
+
) -> Path:
|
| 44 |
+
"""Save exact starts/ends and both episode identities without changing frame data."""
|
| 45 |
+
rows = []
|
| 46 |
+
for camera, spans in report["subtasks_by_camera"].items():
|
| 47 |
+
for span in spans:
|
| 48 |
+
start, end = span["start"], span["end"]
|
| 49 |
+
if not all(type(t) in (int, float) and math.isfinite(t) for t in (start, end)) or end <= start:
|
| 50 |
+
raise ValueError("Invalid camera subtask clock.")
|
| 51 |
+
rows.append({
|
| 52 |
+
"episode_index": output_episode,
|
| 53 |
+
"source_episode_index": report["episode_index"],
|
| 54 |
+
"camera": camera,
|
| 55 |
+
"start_timestamp": float(start),
|
| 56 |
+
"end_timestamp": float(end),
|
| 57 |
+
"subtask": span["text"],
|
| 58 |
+
})
|
| 59 |
+
schema = pa.schema([
|
| 60 |
+
("episode_index", pa.int64()), ("source_episode_index", pa.int64()),
|
| 61 |
+
("camera", pa.string()), ("start_timestamp", pa.float64()),
|
| 62 |
+
("end_timestamp", pa.float64()), ("subtask", pa.string()),
|
| 63 |
+
], metadata={b"annotation_schema": b"camera-subtasks-v1", b"source": json.dumps({
|
| 64 |
+
"repo": manifest.get("dataset"), "revision": manifest.get("dataset_revision"),
|
| 65 |
+
"model": manifest.get("model"), "captioner": manifest.get("captioner", "selected-vlm"),
|
| 66 |
+
}, sort_keys=True).encode()})
|
| 67 |
+
target = Path(root) / "annotation_studio" / "subtasks_by_camera" / f"episode_{output_episode:06d}.parquet"
|
| 68 |
+
target.parent.mkdir(parents=True, exist_ok=True)
|
| 69 |
+
pq.write_table(pa.Table.from_pylist(rows, schema=schema), target, compression="zstd")
|
| 70 |
+
return target
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def preview_timelines(report: dict[str, Any]) -> dict[str, Any]:
|
| 74 |
+
mapping = {m["source_episode_index"]: m["episode_index"] for m in report.get("source_episode_map", [])}
|
| 75 |
+
episodes = []
|
| 76 |
+
for result in report.get("episodes", []):
|
| 77 |
+
source = result.get("episode_index")
|
| 78 |
+
if result.get("status") != "completed" or source not in mapping:
|
| 79 |
+
continue
|
| 80 |
+
views = result.get("subtasks_by_camera")
|
| 81 |
+
if not views:
|
| 82 |
+
continue
|
| 83 |
+
episodes.append({"source_episode_index": source, "episode_index": mapping[source],
|
| 84 |
+
"duration": result["subtask_view_duration_s"], "cameras": views})
|
| 85 |
+
return {"mode": report.get("manifest", {}).get("subtask_views", "primary"), "episodes": episodes}
|
test_studio.py
CHANGED
|
@@ -231,9 +231,9 @@ def test_launch_identity_secret_and_immutable_manifest(mapping_format):
|
|
| 231 |
assert manifest["episode_ids_sha256"] == hashlib.sha256(json.dumps([0, 1]).encode()).hexdigest()
|
| 232 |
assert manifest["space_revision"] == "b" * 40
|
| 233 |
assert manifest["prompts"] == configured["prompts"]
|
| 234 |
-
assert manifest["model"] ==
|
| 235 |
-
assert manifest["request_concurrency"] == manifest["episode_parallelism"] ==
|
| 236 |
-
assert manifest["costs"]["context_length"] ==
|
| 237 |
assert "prompt-templates.json" in str(args["command"])
|
| 238 |
assert manifest["inference_budget"] + plan()["costs"]["cpu_max"] == 5
|
| 239 |
assert result["id"] == "job123"
|
|
|
|
| 231 |
assert manifest["episode_ids_sha256"] == hashlib.sha256(json.dumps([0, 1]).encode()).hexdigest()
|
| 232 |
assert manifest["space_revision"] == "b" * 40
|
| 233 |
assert manifest["prompts"] == configured["prompts"]
|
| 234 |
+
assert manifest["model"] == model_profile(studio.DEFAULT_MODEL)["route"]
|
| 235 |
+
assert manifest["request_concurrency"] == manifest["episode_parallelism"] == model_profile(studio.DEFAULT_MODEL)["concurrency"]
|
| 236 |
+
assert manifest["costs"]["context_length"] == model_profile(studio.DEFAULT_MODEL)["context_length"]
|
| 237 |
assert "prompt-templates.json" in str(args["command"])
|
| 238 |
assert manifest["inference_budget"] + plan()["costs"]["cpu_max"] == 5
|
| 239 |
assert result["id"] == "job123"
|
test_subtask_views.py
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""View isolation, explicit cost scaling and lossless camera/episode output identities."""
|
| 2 |
+
import json
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
from types import SimpleNamespace
|
| 5 |
+
from unittest.mock import Mock
|
| 6 |
+
|
| 7 |
+
import av
|
| 8 |
+
import numpy as np
|
| 9 |
+
import pyarrow.parquet as pq
|
| 10 |
+
import pytest
|
| 11 |
+
from fastapi.testclient import TestClient
|
| 12 |
+
|
| 13 |
+
import server
|
| 14 |
+
import studio
|
| 15 |
+
import worker
|
| 16 |
+
from subtask_views import preview_timelines, select_subtask_cameras, write_view_subtasks
|
| 17 |
+
from test_server import login_client
|
| 18 |
+
from test_studio import selection
|
| 19 |
+
from lerobot.annotations.steerable_pipeline.reader import EpisodeRecord
|
| 20 |
+
from lerobot.annotations.steerable_pipeline.staging import EpisodeStaging
|
| 21 |
+
from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient
|
| 22 |
+
|
| 23 |
+
CAMERAS = ["observation.images.top", "observation.images.wrist"]
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
def test_views_are_explicit_distinct_and_primary_is_kept():
|
| 27 |
+
assert select_subtask_cameras(CAMERAS[0], CAMERAS) == CAMERAS[:1]
|
| 28 |
+
assert select_subtask_cameras(CAMERAS[0], CAMERAS, "per-camera") == CAMERAS
|
| 29 |
+
assert select_subtask_cameras(CAMERAS[0], CAMERAS, "per-camera", [CAMERAS[1]]) == CAMERAS
|
| 30 |
+
for names in [[CAMERAS[1], CAMERAS[1]], ["not-a-camera"], "not-a-list"]:
|
| 31 |
+
with pytest.raises(ValueError):
|
| 32 |
+
select_subtask_cameras(CAMERAS[0], CAMERAS, "per-camera", names)
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def test_estimate_multiplies_only_view_subtasks_not_primary_memory():
|
| 36 |
+
one = studio.estimate(selection(), ["Subtasks"], .1, 1, .0005)
|
| 37 |
+
three = studio.estimate(selection(), ["Subtasks"], .1, 1, .0005, subtask_camera_count=3)
|
| 38 |
+
assert three['inference'] == pytest.approx(3 * one['inference'])
|
| 39 |
+
assert three['calls'] == 3 * one['calls']
|
| 40 |
+
assert three['cpu_max'] == one['cpu_max']
|
| 41 |
+
primary = studio.estimate(selection(), ["Subtasks", "Memory"], .1, 1, .0005)
|
| 42 |
+
multi = studio.estimate(selection(), ["Subtasks", "Memory"], .1, 1, .0005, subtask_camera_count=3)
|
| 43 |
+
assert multi['inference'] - primary['inference'] == pytest.approx(2 * one['inference'])
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
def test_quote_pins_views_and_rejects_missing_cameras_before_hardware_lookup(monkeypatch):
|
| 47 |
+
picked = {**selection(), 'cameras': CAMERAS, 'info': {'fps': 30, 'features': {}}}
|
| 48 |
+
monkeypatch.setattr(studio, 'read_selection', lambda *a, **k: picked)
|
| 49 |
+
hardware = Mock(return_value=SimpleNamespace(list_jobs_hardware=lambda: [SimpleNamespace(name='cpu-upgrade', unit_label='minute', unit_cost_usd=.0005)]))
|
| 50 |
+
monkeypatch.setattr(studio, 'HfApi', hardware)
|
| 51 |
+
q = studio.prepare('owner/data', 0, 2, ['Subtasks'], CAMERAS[0], .1, 1, studio.COPY, subtask_views='per-camera', subtask_cameras=CAMERAS)
|
| 52 |
+
assert q['subtask_cameras'] == CAMERAS and q['costs']['subtask_camera_count'] == 2
|
| 53 |
+
primary = studio.prepare('owner/data', 0, 2, ['Subtasks'], CAMERAS[0], .1, 1, studio.COPY)
|
| 54 |
+
assert q['fingerprint'] != primary['fingerprint']
|
| 55 |
+
hardware.reset_mock()
|
| 56 |
+
with pytest.raises(ValueError):
|
| 57 |
+
studio.prepare('owner/data', 0, 2, ['Subtasks'], CAMERAS[0], .1, 1, studio.COPY, subtask_views='per-camera', subtask_cameras=['missing'])
|
| 58 |
+
hardware.assert_not_called()
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def test_real_pipeline_reads_both_videos_keeps_primary_language_and_exact_clocks(tmp_path, monkeypatch):
|
| 62 |
+
row = {'episode_index': 42, 'length': 40}
|
| 63 |
+
info = {'fps': 10, 'video_path': 'videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4', 'features': {c: {'dtype': 'video'} for c in CAMERAS}}
|
| 64 |
+
for camera, color in zip(CAMERAS, [(180, 0, 0), (0, 0, 180)]):
|
| 65 |
+
path = tmp_path / 'videos' / camera / 'chunk-000/file-000.mp4';path.parent.mkdir(parents=True)
|
| 66 |
+
with av.open(str(path), 'w') as container:
|
| 67 |
+
stream = container.add_stream('mpeg4', rate=10);stream.width=stream.height=64;stream.pix_fmt='yuv420p'
|
| 68 |
+
for i in range(40):
|
| 69 |
+
image=np.zeros((64,64,3),dtype=np.uint8);image[:]=color
|
| 70 |
+
for packet in stream.encode(av.VideoFrame.from_ndarray(image, format='rgb24')):container.mux(packet)
|
| 71 |
+
for packet in stream.encode():container.mux(packet)
|
| 72 |
+
for key,value in [('chunk_index',0),('file_index',0),('from_timestamp',0.)]:row[f'videos/{camera}/{key}']=value
|
| 73 |
+
record = EpisodeRecord(42,'Move the cup',tuple(i/10 for i in range(40)),tuple(range(40)),tmp_path/'unused.parquet',0,40)
|
| 74 |
+
observed=[]
|
| 75 |
+
def respond(messages):
|
| 76 |
+
blocks=[block for message in messages for block in message['content'] if isinstance(block,dict)]
|
| 77 |
+
image=next(block['image'] for block in blocks if block.get('type')=='image')
|
| 78 |
+
rgb=np.array(image).mean((0,1));view='red' if rgb[0]>rgb[2] else 'blue';observed.append(view)
|
| 79 |
+
return {'subtasks':[{'start':0.,'end':1.2,'text':f'Move the {view} cup'},{'start':1.8,'end':3.9,'text':f'Hold the {view} cup'}]}
|
| 80 |
+
manifest={'features':['Subtasks'],'camera':CAMERAS[0],'subtask_views':'per-camera','subtask_cameras':CAMERAS,'prompts':{},'vqa_hz':.1}
|
| 81 |
+
result=worker.annotate_episode(record,row,info,tmp_path,manifest,StubVlmClient(respond),tmp_path/'staging')
|
| 82 |
+
assert set(observed)=={'red','blue'}
|
| 83 |
+
assert result['subtasks_by_camera'][CAMERAS[0]][0]['text']=='Move the red cup'
|
| 84 |
+
assert result['subtasks_by_camera'][CAMERAS[1]][0]['text']=='Move the blue cup'
|
| 85 |
+
assert result['subtasks_by_camera'][CAMERAS[0]][0]['end']==1.8
|
| 86 |
+
staged=EpisodeStaging(tmp_path/'staging',42).read_all();labels=[r['content'] for rows in staged.values() for r in rows if r.get('style')=='subtask']
|
| 87 |
+
assert labels==['Move the red cup','Hold the red cup']
|
| 88 |
+
assert worker.CUSTOM_PROMPTS.get() is None
|
| 89 |
+
file=write_view_subtasks(result,0,tmp_path/'output',{'dataset':'owner/data','dataset_revision':'a'*40})
|
| 90 |
+
table=pq.read_table(file);assert table['episode_index'].to_pylist()==[0]*4;assert table['source_episode_index'].to_pylist()==[42]*4
|
| 91 |
+
assert table['end_timestamp'].to_pylist()==[1.8,3.9,1.8,3.9]
|
| 92 |
+
preview=preview_timelines({'manifest':manifest,'source_episode_map':[{'source_episode_index':42,'episode_index':0}],'episodes':[result]})
|
| 93 |
+
assert preview['episodes'][0]['cameras']==result['subtasks_by_camera']
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
def test_preview_requires_owner_session_and_hides_unexported_results(monkeypatch):
|
| 97 |
+
server.SESSIONS.clear()
|
| 98 |
+
with TestClient(server.app) as client:
|
| 99 |
+
assert client.get('/api/jobs/job/subtask-views').status_code in (401,403)
|
| 100 |
+
login_client(client)
|
| 101 |
+
report={'manifest':{'subtask_views':'per-camera'},'source_episode_map':[], 'episodes':[{'episode_index':42,'status':'completed','subtask_view_duration_s':4.,'subtasks_by_camera':{CAMERAS[0]:[{'start':0.,'end':4.,'text':'Move'}]}}]}
|
| 102 |
+
monkeypatch.setattr(server,'studio_output',lambda *a:(None,'alice/output'))
|
| 103 |
+
monkeypatch.setattr(server,'read_output_file',lambda *a:json.dumps(report).encode())
|
| 104 |
+
assert client.get('/api/jobs/job/subtask-views').json()['episodes']==[]
|
| 105 |
+
report['source_episode_map']=[{'source_episode_index':42,'episode_index':0}]
|
| 106 |
+
data=client.get('/api/jobs/job/subtask-views').json();assert data['episodes'][0]['episode_index']==0
|
| 107 |
+
assert 'never-return-this-token' not in json.dumps(data)
|
worker.py
CHANGED
|
@@ -25,6 +25,7 @@ from provider import Budget, BudgetExceededError, DeadlineExceededError, Provide
|
|
| 25 |
from studio import COPY, FEATURES, HUMAN_VIDEOS, needs_provider, read_selection, relative_path
|
| 26 |
from studio_runtime import run_episode as run_runtime_episode
|
| 27 |
from subset import EpisodeSubset
|
|
|
|
| 28 |
|
| 29 |
from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
|
| 30 |
from lerobot.annotations.steerable_pipeline.executor import Executor
|
|
@@ -307,6 +308,32 @@ def human_video_summary(entries):
|
|
| 307 |
|
| 308 |
|
| 309 |
def _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 310 |
frames = SourceFrames(row, info, root, manifest["camera"])
|
| 311 |
features = manifest["features"]
|
| 312 |
staging = EpisodeStaging(staging_dir, record.episode_index)
|
|
@@ -535,6 +562,9 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 535 |
vision_frame_batching=os.environ.get("FLORA_VISION_FRAME_BATCHING", "0") == "1",
|
| 536 |
)
|
| 537 |
flora_parallelism = int(os.environ.get("FLORA_EPISODE_PARALLELISM", "1"))
|
|
|
|
|
|
|
|
|
|
| 538 |
if flora_parallelism > 1:
|
| 539 |
if FLORA_CAPTIONER.execution != "accelerated" or manifest["features"] != ["Subtasks"]:
|
| 540 |
raise ValueError("Cross-episode Flora requires independent accelerated subtasks only")
|
|
@@ -582,6 +612,10 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 582 |
revision=manifest["dataset_revision"],
|
| 583 |
cache_dir=str(work / "metadata-cache"),
|
| 584 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 585 |
ids = [r["episode_index"] for r in selection["episodes"]]
|
| 586 |
if hashlib.sha256(json.dumps(ids).encode()).hexdigest() != manifest["episode_ids_sha256"]:
|
| 587 |
raise ValueError("Pinned episode selection differs from the estimate.")
|
|
@@ -736,7 +770,7 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 736 |
print('HAND_RUNTIME {"event":"camera_engines_ready","native_worker":"starting"}', flush=True)
|
| 737 |
HAND_ANNOTATOR = HandAnnotator(backend, work / "hands-native")
|
| 738 |
for data_path, rows in shards.items():
|
| 739 |
-
files = {data_path} | {video_path(row, info,
|
| 740 |
sizes = api.get_paths_info(
|
| 741 |
manifest["dataset"], list(files), repo_type="dataset", revision=manifest["dataset_revision"]
|
| 742 |
)
|
|
@@ -827,6 +861,9 @@ def run(manifest, work=Path("/workspace/run")):
|
|
| 827 |
new_id, written = subset.add(
|
| 828 |
record.episode_index, annotated_table, hand_features=hand_features
|
| 829 |
)
|
|
|
|
|
|
|
|
|
|
| 830 |
extra_operations = []
|
| 831 |
with commit_lock:
|
| 832 |
for entry in human_index:
|
|
|
|
| 25 |
from studio import COPY, FEATURES, HUMAN_VIDEOS, needs_provider, read_selection, relative_path
|
| 26 |
from studio_runtime import run_episode as run_runtime_episode
|
| 27 |
from subset import EpisodeSubset
|
| 28 |
+
from subtask_views import VIEW_INSTRUCTIONS, select_subtask_cameras, write_view_subtasks
|
| 29 |
|
| 30 |
from lerobot.annotations.steerable_pipeline.config import InterjectionsConfig, PlanConfig, VqaConfig
|
| 31 |
from lerobot.annotations.steerable_pipeline.executor import Executor
|
|
|
|
| 308 |
|
| 309 |
|
| 310 |
def _annotate_episode(record, row, info, root, manifest, provider, staging_dir, media=None):
|
| 311 |
+
if manifest.get("subtask_views", "primary") != "per-camera":
|
| 312 |
+
return _annotate_single_camera(record, row, info, root, manifest, provider, staging_dir, media)
|
| 313 |
+
available = [key for key, feature in info["features"].items() if feature["dtype"] == "video"]
|
| 314 |
+
cameras = select_subtask_cameras(manifest["camera"], available, "per-camera", manifest.get("subtask_cameras"))
|
| 315 |
+
prompts = dict(CUSTOM_PROMPTS.get() or {})
|
| 316 |
+
prompts["plan_subtasks"] = prompts.get("plan_subtasks", ACTION_PROMPT) + "\n" + VIEW_INSTRUCTIONS
|
| 317 |
+
context = CUSTOM_PROMPTS.set(prompts)
|
| 318 |
+
try:
|
| 319 |
+
result = _annotate_single_camera(record, row, info, root, manifest, provider, staging_dir, media)
|
| 320 |
+
by_camera = {manifest["camera"]: result["subtask_spans"]}
|
| 321 |
+
recovery = {manifest["camera"]: result["recovery_used"]}
|
| 322 |
+
for camera in cameras[1:]:
|
| 323 |
+
view_dir = staging_dir.parent / "camera-subtasks" / str(record.episode_index) / hashlib.sha256(camera.encode()).hexdigest()[:16]
|
| 324 |
+
view_manifest = {**manifest, "camera": camera, "features": ["Subtasks"], "hand_actions": None, "human_video": None}
|
| 325 |
+
view = _annotate_single_camera(record, row, info, root, view_manifest, provider, view_dir, None)
|
| 326 |
+
by_camera[camera] = view["subtask_spans"]
|
| 327 |
+
recovery[camera] = view["recovery_used"]
|
| 328 |
+
shutil.rmtree(view_dir, ignore_errors=True)
|
| 329 |
+
result.update(subtasks_by_camera=by_camera, subtask_view_recovery=recovery,
|
| 330 |
+
subtask_view_duration_s=int(row["length"]) / float(info["fps"]))
|
| 331 |
+
return result
|
| 332 |
+
finally:
|
| 333 |
+
CUSTOM_PROMPTS.reset(context)
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
def _annotate_single_camera(record, row, info, root, manifest, provider, staging_dir, media=None):
|
| 337 |
frames = SourceFrames(row, info, root, manifest["camera"])
|
| 338 |
features = manifest["features"]
|
| 339 |
staging = EpisodeStaging(staging_dir, record.episode_index)
|
|
|
|
| 562 |
vision_frame_batching=os.environ.get("FLORA_VISION_FRAME_BATCHING", "0") == "1",
|
| 563 |
)
|
| 564 |
flora_parallelism = int(os.environ.get("FLORA_EPISODE_PARALLELISM", "1"))
|
| 565 |
+
if manifest.get("subtask_views") == "per-camera":
|
| 566 |
+
# A resident captioner is shared; do not key different views by the same episode ID.
|
| 567 |
+
flora_parallelism = 1
|
| 568 |
if flora_parallelism > 1:
|
| 569 |
if FLORA_CAPTIONER.execution != "accelerated" or manifest["features"] != ["Subtasks"]:
|
| 570 |
raise ValueError("Cross-episode Flora requires independent accelerated subtasks only")
|
|
|
|
| 612 |
revision=manifest["dataset_revision"],
|
| 613 |
cache_dir=str(work / "metadata-cache"),
|
| 614 |
)
|
| 615 |
+
|
| 616 |
+
subtask_cameras = select_subtask_cameras(manifest["camera"], selection["cameras"], manifest.get("subtask_views", "primary"), manifest.get("subtask_cameras"))
|
| 617 |
+
if manifest.get("subtask_views") == "per-camera" and "Subtasks" not in manifest["features"]:
|
| 618 |
+
raise ValueError("Per-camera subtasks must be selected for export")
|
| 619 |
ids = [r["episode_index"] for r in selection["episodes"]]
|
| 620 |
if hashlib.sha256(json.dumps(ids).encode()).hexdigest() != manifest["episode_ids_sha256"]:
|
| 621 |
raise ValueError("Pinned episode selection differs from the estimate.")
|
|
|
|
| 770 |
print('HAND_RUNTIME {"event":"camera_engines_ready","native_worker":"starting"}', flush=True)
|
| 771 |
HAND_ANNOTATOR = HandAnnotator(backend, work / "hands-native")
|
| 772 |
for data_path, rows in shards.items():
|
| 773 |
+
files = {data_path} | {video_path(row, info, camera) for row in rows for camera in subtask_cameras}
|
| 774 |
sizes = api.get_paths_info(
|
| 775 |
manifest["dataset"], list(files), repo_type="dataset", revision=manifest["dataset_revision"]
|
| 776 |
)
|
|
|
|
| 861 |
new_id, written = subset.add(
|
| 862 |
record.episode_index, annotated_table, hand_features=hand_features
|
| 863 |
)
|
| 864 |
+
if manifest.get("subtask_views") == "per-camera":
|
| 865 |
+
report = next(e for e in progress["episodes"] if e["episode_index"] == record.episode_index)
|
| 866 |
+
written = [*written, write_view_subtasks(report, new_id, subset.root, manifest)]
|
| 867 |
extra_operations = []
|
| 868 |
with commit_lock:
|
| 869 |
for entry in human_index:
|