pepijn223 HF Staff commited on
Commit
09d3c40
·
verified ·
1 Parent(s): a0f832c

Add optional independent per-camera subtask timelines

Browse files
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({ features: [SLAM], captioner: "selected-vlm" });
 
 
 
 
 
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(options),
 
 
 
 
 
 
 
 
 
 
 
 
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
- One camera view per run.
 
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>Camera view</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 { grid-template-columns: 1fr; }
 
 
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
- calls = windows if provider_subtasks else 0
 
 
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"] == "Qwen/Qwen3.8-27B:novita"
235
- assert manifest["request_concurrency"] == manifest["episode_parallelism"] == 32
236
- assert manifest["costs"]["context_length"] == 1_000_000
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, manifest["camera"]) for row in rows}
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: