pepijn223 HF Staff commited on
Commit
285e72f
·
verified ·
1 Parent(s): acdad44

Integrate Hand actions into the annotation wizard with HF Jobs and private MANO assets

Browse files
Dockerfile CHANGED
@@ -11,8 +11,9 @@ WORKDIR /app
11
  COPY requirements.txt ./
12
  RUN pip install --no-cache-dir -r requirements.txt
13
  COPY --chown=user:user . .
 
14
  COPY --from=frontend --chown=user:user /build/dist /app/frontend/dist
15
  USER user
16
- ENV HOME=/home/user PYTHONUNBUFFERED=1 HF_HUB_DISABLE_TELEMETRY=1
17
  EXPOSE 7860
18
  CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860", "--no-access-log"]
 
11
  COPY requirements.txt ./
12
  RUN pip install --no-cache-dir -r requirements.txt
13
  COPY --chown=user:user . .
14
+ RUN python bootstrap.py
15
  COPY --from=frontend --chown=user:user /build/dist /app/frontend/dist
16
  USER user
17
+ ENV HOME=/home/user PYTHONUNBUFFERED=1 HF_HUB_DISABLE_TELEMETRY=1 PYTHONPATH=/workspace/source/src
18
  EXPOSE 7860
19
  CMD ["uvicorn", "server:app", "--host", "0.0.0.0", "--port", "7860", "--no-access-log"]
Dockerfile.flora CHANGED
@@ -1,6 +1,6 @@
1
  # Build once on CPU; no credentials or model weights are embedded in this image.
2
  # Verified upstream PyTorch image digest, CUDA 12.8 / Torch 2.11.0.
3
- FROM pytorch/pytorch:2.11.0-cuda12.8-cudnn9-devel@sha256:53ab3de62f6101d1e42f9be28623ab7a468a24c070d632f211ed576e30b6abd3
4
  ENV DEBIAN_FRONTEND=noninteractive TORCH_CUDA_ARCH_LIST=9.0 MAX_JOBS=4
5
  RUN apt-get update && apt-get install -y --no-install-recommends python3-venv \
6
  && rm -rf /var/lib/apt/lists/* \
@@ -11,11 +11,23 @@ COPY worker-requirements.txt flora-requirements.txt flora-acceleration-requireme
11
  # Keep the reference worker/native dependency overlay order.
12
  RUN python -m pip install --no-cache-dir -r worker-requirements.txt
13
  RUN python -m pip install --no-cache-dir -r flora-requirements.txt
 
 
 
14
  RUN python -m pip install --no-cache-dir --no-build-isolation -r flora-acceleration-requirements.txt
 
15
  COPY *.py *.txt prompt-templates.json pipeline-source.tar.gz pipeline-version.json ./
16
  RUN python bootstrap.py
 
 
 
 
 
 
 
17
  ENV PYTHONPATH=/workspace/source/src:/workspace/app PYTHONUNBUFFERED=1 HF_HUB_DISABLE_TELEMETRY=1
18
- ENV FLORA_EXECUTION=accelerated FLORA_BATCH_SIZE=4 FLORA_ENABLE_INFERENCE=1
 
19
  # CPU Space builds expose only health information. A GPU Job overrides this CMD
20
  # with python /workspace/app/worker.py and supplies its own scoped HF secret.
21
  EXPOSE 7860
 
1
  # Build once on CPU; no credentials or model weights are embedded in this image.
2
  # Verified upstream PyTorch image digest, CUDA 12.8 / Torch 2.11.0.
3
+ FROM pytorch/pytorch:2.11.0-cuda12.8-cudnn9-devel@sha256:53ab3de62f6101d1e42f9be28623ab7a468a24c070d632f211ed576e30b6abd3 AS build
4
  ENV DEBIAN_FRONTEND=noninteractive TORCH_CUDA_ARCH_LIST=9.0 MAX_JOBS=4
5
  RUN apt-get update && apt-get install -y --no-install-recommends python3-venv \
6
  && rm -rf /var/lib/apt/lists/* \
 
11
  # Keep the reference worker/native dependency overlay order.
12
  RUN python -m pip install --no-cache-dir -r worker-requirements.txt
13
  RUN python -m pip install --no-cache-dir -r flora-requirements.txt
14
+ COPY build_causal_conv1d.py ./
15
+ ENV MAX_JOBS=1
16
+ RUN python build_causal_conv1d.py
17
  RUN python -m pip install --no-cache-dir --no-build-isolation -r flora-acceleration-requirements.txt
18
+ RUN python -c "import causal_conv1d; from fla.ops.gated_delta_rule import chunk_gated_delta_rule, fused_recurrent_gated_delta_rule"
19
  COPY *.py *.txt prompt-templates.json pipeline-source.tar.gz pipeline-version.json ./
20
  RUN python bootstrap.py
21
+
22
+ # Keep the 10-GB compiler toolchain out of each paid Job's image pull.
23
+ FROM pytorch/pytorch:2.11.0-cuda12.8-cudnn9-runtime@sha256:eee11b3b3872a8c838e35ef48f08b2d5def2080902c7f666831310ca1a0ef2be
24
+ COPY --from=build /opt/flora-venv /opt/flora-venv
25
+ COPY --from=build /workspace /workspace
26
+ WORKDIR /workspace/app
27
+ ENV PATH=/opt/flora-venv/bin:$PATH
28
  ENV PYTHONPATH=/workspace/source/src:/workspace/app PYTHONUNBUFFERED=1 HF_HUB_DISABLE_TELEMETRY=1
29
+ ENV FLORA_EXECUTION=accelerated FLORA_BATCH_SIZE=8 FLORA_ENABLE_INFERENCE=1
30
+ RUN python -c "import worker, torch, causal_conv1d; from fla.ops.gated_delta_rule import chunk_gated_delta_rule, fused_recurrent_gated_delta_rule; assert torch.__version__.split('+')[0] == '2.11.0'"
31
  # CPU Space builds expose only health information. A GPU Job overrides this CMD
32
  # with python /workspace/app/worker.py and supplies its own scoped HF secret.
33
  EXPOSE 7860
build_causal_conv1d.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build pinned upstream kernels for H200 with one compiler process/thread."""
2
+
3
+ import hashlib
4
+ import os
5
+ import subprocess
6
+ import sys
7
+ import tarfile
8
+ import tempfile
9
+ from pathlib import Path
10
+
11
+ import requests
12
+
13
+ URL = "https://files.pythonhosted.org/packages/f4/d1/eba27735f31bd5527d39a3c7f693e6ded7b844cab275db719a15ad37b6cd/causal_conv1d-1.7.0.tar.gz"
14
+ SHA256 = "3202758494eaa7b597ce1c282dfa188889506bfcb92cad3c407d26736bfcd32b"
15
+
16
+
17
+ def main():
18
+ response = requests.get(URL, timeout=60)
19
+ response.raise_for_status()
20
+ if hashlib.sha256(response.content).hexdigest() != SHA256:
21
+ raise RuntimeError("Causal convolution source checksum mismatch")
22
+ with tempfile.TemporaryDirectory() as temporary:
23
+ archive = Path(temporary) / "source.tar.gz"
24
+ archive.write_bytes(response.content)
25
+ with tarfile.open(archive) as source:
26
+ source.extractall(temporary, filter="data")
27
+ root = Path(temporary) / "causal_conv1d-1.7.0"
28
+ setup = root / "setup.py"
29
+ content = setup.read_text()
30
+ old = 'return nvcc_extra_args + ["--threads", "4"]'
31
+ if content.count(old) != 1:
32
+ raise RuntimeError("Upstream compiler thread configuration changed")
33
+ # Compile only Hopper SM90, the hardware this image is explicitly for.
34
+ # Kernel sources and numeric flags are unchanged; omit unused GPU targets.
35
+ start = content.index(
36
+ ' cc_flag.append("-gencode")\n cc_flag.append("arch=compute_75,code=sm_75")'
37
+ )
38
+ end = content.index(" # HACK:", start)
39
+ content = (
40
+ content[:start]
41
+ + ' cc_flag += ["-gencode", "arch=compute_90,code=sm_90"]\n'
42
+ + content[end:]
43
+ )
44
+ setup.write_text(content.replace(old, 'return nvcc_extra_args + ["--threads", "1"]'))
45
+ env = {**os.environ, "MAX_JOBS": "1", "CAUSAL_CONV1D_FORCE_BUILD": "TRUE"}
46
+ subprocess.run(
47
+ [sys.executable, "-m", "pip", "install", "--no-cache-dir", "--no-build-isolation", str(root)],
48
+ env=env,
49
+ check=True,
50
+ )
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
deploy.py CHANGED
@@ -92,8 +92,8 @@ def main():
92
  ".cache/**",
93
  ".git/**",
94
  ],
95
- delete_patterns=["app.py"],
96
- commit_message="Use scalable annotation runtime, shared human-video module and optional Flora captions",
97
  )
98
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
99
 
 
92
  ".cache/**",
93
  ".git/**",
94
  ],
95
+ delete_patterns=["app.py", "frontend/src/HandSetupView.tsx", "frontend/src/hands.css"],
96
+ commit_message="Integrate Hand actions into the annotation wizard with HF Jobs and private MANO assets",
97
  )
98
  print(f"https://huggingface.co/spaces/{repo}\nRevision: {result.oid}")
99
 
flora_acceleration.py CHANGED
@@ -14,6 +14,8 @@ def require_fast_kernels():
14
 
15
  if not torch.cuda.is_available() or not modeling_qwen3_5.is_fast_path_available:
16
  raise RuntimeError("Accelerated Flora needs CUDA, flash-linear-attention and causal-conv1d")
 
 
17
  return {
18
  name: version(name) for name in ("torch", "transformers", "flash-linear-attention", "causal-conv1d")
19
  }
 
14
 
15
  if not torch.cuda.is_available() or not modeling_qwen3_5.is_fast_path_available:
16
  raise RuntimeError("Accelerated Flora needs CUDA, flash-linear-attention and causal-conv1d")
17
+ if torch.cuda.get_device_capability(0) != (9, 0):
18
+ raise RuntimeError("This accelerated Flora image is compiled for H200/Hopper SM90")
19
  return {
20
  name: version(name) for name in ("torch", "transformers", "flash-linear-attention", "causal-conv1d")
21
  }
flora_image.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Only enable a prebuilt worker after a complete, measured acceptance run."""
2
+
3
+ import json
4
+ import math
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from flora_captioner import FLORA
9
+ from huggingface_hub.utils import validate_repo_id
10
+
11
+ IDENTITY_FILES = {
12
+ "worker.py",
13
+ "subset.py",
14
+ "flora_captioner.py",
15
+ "flora_acceleration.py",
16
+ "flora_speed_entry.py",
17
+ "studio_runtime.py",
18
+ "pipeline-version.json",
19
+ }
20
+
21
+
22
+ def validated_worker_image(captioner, features, path=None):
23
+ if captioner != "flora" or set(features) != {"Subtasks"}:
24
+ return None
25
+ path = path or Path(__file__).with_name("flora-worker-image.json")
26
+ if not path.exists():
27
+ return None
28
+ profile = json.loads(path.read_text())
29
+ if type(profile.get("batch_size", 4)) is not int or profile.get("batch_size", 4) not in (1, 2, 4, 8):
30
+ raise ValueError("Accelerated Flora batch size is not validated")
31
+ accepted = profile.get("acceptance", {})
32
+ seconds = accepted.get("end_to_end_seconds")
33
+ if (
34
+ accepted.get("episodes") != 25
35
+ or accepted.get("independent") is not True
36
+ or accepted.get("fused_kernels") is not True
37
+ or not isinstance(seconds, (int, float))
38
+ or isinstance(seconds, bool)
39
+ or not math.isfinite(seconds)
40
+ or not 0 < seconds < 900
41
+ or profile.get("captioner_profile") != FLORA
42
+ ):
43
+ raise ValueError("Accelerated Flora worker has not passed its acceptance run")
44
+ validate_repo_id(profile["repo"])
45
+ if not re.fullmatch(r"[a-f0-9]{40}", profile["revision"]):
46
+ raise ValueError("Accelerated Flora image revision must be pinned")
47
+ hashes = profile.get("expected_code", {})
48
+ if set(hashes) != IDENTITY_FILES or any(
49
+ not re.fullmatch(r"[a-f0-9]{64}", value) for value in hashes.values()
50
+ ):
51
+ raise ValueError("Accelerated Flora code checksums are incomplete")
52
+ return profile
frontend/src/HandActionSettings.tsx ADDED
@@ -0,0 +1,169 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { ArrowUpRight, Hand } from "lucide-react";
2
+
3
+ export type HandOptions = {
4
+ compute: string;
5
+ planning_fps: number | null;
6
+ calibration: string;
7
+ mano_repo: string;
8
+ research_rights: boolean;
9
+ };
10
+ export type HandCatalog = {
11
+ available: boolean;
12
+ reason: string | null;
13
+ profiles: { key: string; name: string; reference_fps: number | null }[];
14
+ };
15
+ export const defaultHands: HandOptions = {
16
+ compute: "h200",
17
+ planning_fps: null,
18
+ calibration: "",
19
+ mano_repo: "",
20
+ research_rights: false,
21
+ };
22
+
23
+ export default function HandActionSettings({
24
+ value,
25
+ catalog,
26
+ onChange,
27
+ }: {
28
+ value: HandOptions;
29
+ catalog: HandCatalog | null;
30
+ onChange: (value: HandOptions) => void;
31
+ }) {
32
+ const update = (patch: Partial<HandOptions>) =>
33
+ onChange({ ...value, ...patch });
34
+ return (
35
+ <section
36
+ className="card step-card hand-settings"
37
+ aria-label="Hand action settings"
38
+ >
39
+ <div className="category-heading">
40
+ <h3>
41
+ <Hand size={16} /> Hand actions
42
+ </h3>
43
+ <p className="field-help">
44
+ Calibrated HaWoR / MANO hands and motion infilling with DROID-W camera
45
+ tracking. Runs in your Hugging Face Jobs account.
46
+ </p>
47
+ </div>
48
+ {!catalog?.available && (
49
+ <p className="message" role="status">
50
+ {catalog?.reason || "Reading hand runtime availability…"}
51
+ </p>
52
+ )}
53
+ <label className="field-label" htmlFor="hand-compute">
54
+ HF Jobs GPU
55
+ </label>
56
+ <select
57
+ id="hand-compute"
58
+ value={value.compute}
59
+ onChange={(e) =>
60
+ update({ compute: e.target.value, planning_fps: null })
61
+ }
62
+ >
63
+ {(catalog?.profiles || [{ key: "h200", name: "H200 141 GB" }]).map(
64
+ (p) => (
65
+ <option key={p.key} value={p.key}>
66
+ {p.name}
67
+ </option>
68
+ ),
69
+ )}
70
+ </select>
71
+ <label className="field-label" htmlFor="hand-fps">
72
+ Planning speed · input FPS
73
+ </label>
74
+ <input
75
+ id="hand-fps"
76
+ type="number"
77
+ min={0.1}
78
+ step="any"
79
+ placeholder="Not measured on this GPU"
80
+ value={value.planning_fps ?? ""}
81
+ onChange={(e) =>
82
+ update({
83
+ planning_fps: e.target.value ? Number(e.target.value) : null,
84
+ })
85
+ }
86
+ />
87
+ <p className="field-help">
88
+ HF GPU throughput has not been measured yet. Enter an assumption to
89
+ estimate processing time. Historical reference: 53.9 input FPS on RTX
90
+ 5090, across ten short calibrated clips. Setup, downloads and copying
91
+ add time. Source video FPS stays unchanged.
92
+ </p>
93
+ <label className="field-label" htmlFor="hand-calibration">
94
+ Camera calibration
95
+ </label>
96
+ <textarea
97
+ id="hand-calibration"
98
+ rows={5}
99
+ value={value.calibration}
100
+ onChange={(e) => update({ calibration: e.target.value })}
101
+ placeholder={
102
+ '{"model":"pinhole","width":1408,"height":1408,"fx":700,"fy":700,"cx":704,"cy":704,"distortion":[]}'
103
+ }
104
+ />
105
+ <p className="field-help">
106
+ Leave blank to use this camera’s dataset calibration. Otherwise enter
107
+ measured calibration matching the video resolution. The example shows
108
+ the format; it is not a calibration for your camera.
109
+ </p>
110
+ <label className="field-label" htmlFor="mano-repo">
111
+ Your private MANO model repository
112
+ </label>
113
+ <input
114
+ id="mano-repo"
115
+ placeholder="your-account/licensed-mano"
116
+ value={value.mano_repo}
117
+ onChange={(e) => update({ mano_repo: e.target.value })}
118
+ />
119
+ <p className="field-help">
120
+ Contains your licensed MANO_LEFT.safetensors and MANO_RIGHT.safetensors.
121
+ The Job downloads them with your HF login and checks private ownership
122
+ and the pinned revision. Model files are never included in the output
123
+ dataset.
124
+ </p>
125
+ <label className="hand-consent">
126
+ <input
127
+ type="checkbox"
128
+ checked={value.research_rights}
129
+ onChange={(e) => update({ research_rights: e.target.checked })}
130
+ />
131
+ <span>
132
+ I obtained MANO lawfully and have permission to use MANO and the other
133
+ models for non-commercial research on my HF Jobs compute.
134
+ </span>
135
+ </label>
136
+ <p className="field-help">
137
+ <a href="https://mano.is.tue.mpg.de/" target="_blank" rel="noreferrer">
138
+ Official MANO access <ArrowUpRight size={12} />
139
+ </a>{" "}
140
+ ·{" "}
141
+ <a
142
+ href="https://mano.is.tue.mpg.de/license.html"
143
+ target="_blank"
144
+ rel="noreferrer"
145
+ >
146
+ MANO terms
147
+ </a>{" "}
148
+ ·{" "}
149
+ <a
150
+ href="https://huggingface.co/macrodata/egovision-safetensors"
151
+ target="_blank"
152
+ rel="noreferrer"
153
+ >
154
+ Other model terms
155
+ </a>
156
+ . This acknowledgment does not grant a license. Public MANO mirrors are
157
+ disabled.
158
+ </p>
159
+ <p className="field-help">
160
+ The output keeps all selected episodes and frames. It adds 102D actions,
161
+ a validity mask and a reference-frame index. Existing actions are
162
+ preserved as annotation.original_action. Unmeasured frames are masked;
163
+ use annotation.hand_action_valid to exclude unrecoverable frames, or
164
+ annotation.hand_action_observed_valid for strict observed-only training.
165
+ Infilled predictions are retained and flagged per hand.
166
+ </p>
167
+ </section>
168
+ );
169
+ }
frontend/src/HandSetupView.tsx DELETED
@@ -1,353 +0,0 @@
1
- import { useEffect, useState } from "react";
2
- import { ArrowUpRight, Download, Hand, Upload } from "lucide-react";
3
- import "./hands.css";
4
-
5
- type RunConfig = {
6
- input_root: string;
7
- output_root: string;
8
- compute: string;
9
- planning_fps: number | null;
10
- backend: Record<string, string | number | boolean>;
11
- runtime: {
12
- backend: string;
13
- workers: number;
14
- hf_jobs: Record<string, unknown>;
15
- [key: string]: unknown;
16
- };
17
- [key: string]: unknown;
18
- };
19
- type Catalog = {
20
- profiles: {
21
- key: string;
22
- name: string;
23
- reference_fps: number | null;
24
- hf_flavor: string | null;
25
- }[];
26
- config_template: RunConfig;
27
- reference_url: string;
28
- lerobot_revision: string;
29
- licenses: { mano: string; models: string };
30
- };
31
-
32
- export default function HandSetupView() {
33
- const [catalog, setCatalog] = useState<Catalog | null>(null);
34
- const [imported, setImported] = useState<RunConfig | null>(null);
35
- const [input, setInput] = useState("raw-human-pov");
36
- const [output, setOutput] = useState("annotated-human-pov");
37
- const [compute, setCompute] = useState("rtx-5090");
38
- const [backend, setBackend] = useState("local");
39
- const [workers, setWorkers] = useState(1);
40
- const [planning, setPlanning] = useState("");
41
- const [accepted, setAccepted] = useState(false);
42
- const [error, setError] = useState("");
43
- const [saved, setSaved] = useState(false);
44
- useEffect(
45
- () => setSaved(false),
46
- [compute, backend, workers, planning, accepted, imported, input, output],
47
- );
48
- useEffect(() => {
49
- fetch("/api/hand-pipeline")
50
- .then((response) => {
51
- if (!response.ok)
52
- throw new Error(
53
- "The hand pipeline catalog is unavailable. Please refresh.",
54
- );
55
- return response.json();
56
- })
57
- .then(setCatalog)
58
- .catch((e) => setError(e.message));
59
- }, []);
60
- const selected = catalog?.profiles.find((p) => p.key === compute);
61
- const fps = planning ? Number(planning) : selected?.reference_fps;
62
- const valid =
63
- !!catalog &&
64
- !!input.trim() &&
65
- !!output.trim() &&
66
- input !== output &&
67
- Number.isInteger(workers) &&
68
- workers >= 1 &&
69
- workers <= 8 &&
70
- (!planning ||
71
- (Number.isFinite(Number(planning)) && Number(planning) > 0)) &&
72
- !(backend === "hf_jobs" && !selected?.hf_flavor);
73
-
74
- async function importConfig(file: File | undefined) {
75
- if (!file) return;
76
- try {
77
- if (file.size > 1_000_000)
78
- throw new Error("Choose a configuration file smaller than 1 MB.");
79
- const value = JSON.parse(await file.text());
80
- if (
81
- !value ||
82
- typeof value !== "object" ||
83
- !value.backend ||
84
- typeof value.backend !== "object" ||
85
- Array.isArray(value.backend) ||
86
- !value.runtime ||
87
- typeof value.runtime !== "object" ||
88
- Array.isArray(value.runtime)
89
- )
90
- throw new Error(
91
- "Choose a hand pipeline JSON with backend and runtime settings.",
92
- );
93
- setImported(value);
94
- setError("");
95
- setSaved(false);
96
- } catch (e) {
97
- setError((e as Error).message);
98
- }
99
- }
100
- function download() {
101
- if (!valid || !catalog) return;
102
- const base = imported || catalog.config_template;
103
- const config = {
104
- ...base,
105
- input_root: input.trim(),
106
- output_root: output.trim(),
107
- compute,
108
- planning_fps: planning ? Number(planning) : null,
109
- backend: { ...base.backend, research_terms_accepted: accepted },
110
- runtime: {
111
- ...base.runtime,
112
- backend,
113
- workers,
114
- hf_jobs: {
115
- ...base.runtime.hf_jobs,
116
- code_repository: "https://github.com/pkooij/lerobot.git",
117
- code_revision: catalog.lerobot_revision,
118
- },
119
- },
120
- };
121
- const url = URL.createObjectURL(
122
- new Blob([JSON.stringify(config, null, 2) + "\n"], {
123
- type: "application/json",
124
- }),
125
- );
126
- const link = document.createElement("a");
127
- link.href = url;
128
- link.download = "hands.json";
129
- link.click();
130
- setTimeout(() => URL.revokeObjectURL(url), 1000);
131
- setSaved(true);
132
- }
133
- return (
134
- <div className="wizard hand-setup">
135
- <div className="page-heading">
136
- <div>
137
- <h1>Human POV hands</h1>
138
- <p>
139
- Turn calibrated human recordings into 3D hands and LeRobot action
140
- episodes.
141
- </p>
142
- </div>
143
- </div>
144
- <div className="step-card">
145
- <h2>
146
- <Hand size={20} /> Research pipeline
147
- </h2>
148
- <p>
149
- DROID-W camera tracking · calibrated HaWoR / MANO · 102D wrist and
150
- finger actions.
151
- </p>
152
- <p className="field-help">
153
- Only continuous segments with both hands directly observed are
154
- exported. Missing hands and short segments are reported as rejected.
155
- </p>
156
- <div className="hand-fields">
157
- <label>
158
- Input folder
159
- <input
160
- value={input}
161
- onChange={(e) => {
162
- setInput(e.target.value);
163
- setSaved(false);
164
- }}
165
- />
166
- <small>
167
- MP4s with matching .intrinsics.json calibration files.
168
- </small>
169
- </label>
170
- <label>
171
- Output folder
172
- <input
173
- value={output}
174
- onChange={(e) => {
175
- setOutput(e.target.value);
176
- setSaved(false);
177
- }}
178
- />
179
- </label>
180
- </div>
181
- </div>
182
- <div className="step-card">
183
- <h2>Compute</h2>
184
- <div className="hand-fields">
185
- <label>
186
- <span>Run on</span>
187
- <select
188
- value={backend}
189
- onChange={(e) => {
190
- setBackend(e.target.value);
191
- if (e.target.value === "hf_jobs" && !selected?.hf_flavor) {
192
- setCompute("h200");
193
- setPlanning("");
194
- }
195
- }}
196
- >
197
- <option value="local">Your GPU machine</option>
198
- <option value="slurm">Your Slurm cluster</option>
199
- <option value="hf_jobs">Your Hugging Face Jobs image</option>
200
- </select>
201
- </label>
202
- <label>
203
- <span>GPU</span>
204
- <select
205
- value={compute}
206
- onChange={(e) => {
207
- setCompute(e.target.value);
208
- setPlanning("");
209
- }}
210
- >
211
- {(
212
- catalog?.profiles || [
213
- { key: "rtx-5090", name: "RTX 5090", hf_flavor: null },
214
- ]
215
- ).map((p) => (
216
- <option
217
- value={p.key}
218
- key={p.key}
219
- disabled={backend === "hf_jobs" && !p.hf_flavor}
220
- >
221
- {p.name}
222
- </option>
223
- ))}
224
- </select>
225
- </label>
226
- <label>
227
- GPU workers
228
- <input
229
- type="number"
230
- min={1}
231
- max={8}
232
- value={workers}
233
- onChange={(e) => setWorkers(Number(e.target.value))}
234
- />
235
- <small>
236
- One GPU per worker. Use enough video groups to keep them busy.
237
- </small>
238
- </label>
239
- <label>
240
- Planning FPS per GPU
241
- <input
242
- type="number"
243
- min={0.1}
244
- step="any"
245
- value={planning}
246
- placeholder={
247
- selected?.reference_fps
248
- ? String(selected.reference_fps)
249
- : "Enter an assumption"
250
- }
251
- onChange={(e) => setPlanning(e.target.value)}
252
- />
253
- </label>
254
- </div>
255
- <div className="hand-speed" aria-live="polite">
256
- <strong>
257
- {fps && Number.isFinite(fps) && fps > 0
258
- ? `~${fps} input FPS per GPU`
259
- : "GPU throughput has not been measured yet"}
260
- </strong>
261
- <p>
262
- {planning
263
- ? "Your planning assumption; not a measured benchmark."
264
- : selected?.reference_fps
265
- ? "Historical full-pipeline result on RTX 5090: 1,500 frames across ten short HOT3D clips, including model startup and export."
266
- : "The measured reference is ~53.9 input FPS on RTX 5090. Enter an assumption for this GPU to estimate runtime."}
267
- </p>
268
- <p>
269
- Source video FPS is preserved. Processing speed varies with
270
- resolution, clip length, visibility, CPU and storage. First-time
271
- setup, transfer and queue time are additional.
272
- </p>
273
- {catalog && (
274
- <a href={catalog.reference_url} target="_blank" rel="noreferrer">
275
- Benchmark and recipe <ArrowUpRight size={13} />
276
- </a>
277
- )}
278
- </div>
279
- {backend === "hf_jobs" && (
280
- <p className="field-help">
281
- Requires a private, authorized runtime image pinned by digest and a
282
- remote artifact store in your imported configuration. RTX 5090 is a
283
- local profile.
284
- </p>
285
- )}
286
- </div>
287
- <div className="step-card">
288
- <h2>Your research runtime</h2>
289
- <p>
290
- Run with your licensed model assets and calibrated camera setup. Model
291
- files are supplied by your runtime.
292
- </p>
293
- <label className="hand-import">
294
- <Upload size={16} /> Import runtime configuration
295
- <input
296
- type="file"
297
- accept="application/json,.json"
298
- onChange={(e) => importConfig(e.target.files?.[0])}
299
- />
300
- </label>
301
- <p className="field-help">
302
- {imported
303
- ? "Runtime settings imported locally in this browser."
304
- : "Without an import, the download is a setup template. Fill in backend paths, asset hashes and scheduler settings before running."}
305
- </p>
306
- <label className="hand-consent">
307
- <input
308
- type="checkbox"
309
- checked={accepted}
310
- onChange={(e) => setAccepted(e.target.checked)}
311
- />
312
- <span>
313
- I have the required MANO and upstream model rights for
314
- non-commercial research.
315
- </span>
316
- </label>
317
- {catalog && (
318
- <p className="field-help">
319
- <a href={catalog.licenses.mano} target="_blank" rel="noreferrer">
320
- MANO terms
321
- </a>{" "}
322
- ·{" "}
323
- <a href={catalog.licenses.models} target="_blank" rel="noreferrer">
324
- Other model terms
325
- </a>
326
- . A research acknowledgment does not grant redistribution or
327
- commercial rights.
328
- </p>
329
- )}
330
- </div>
331
- {error && (
332
- <p className="message error" role="alert">
333
- {error}
334
- </p>
335
- )}
336
- <div className="hand-finish">
337
- <button className="button primary" onClick={download} disabled={!valid}>
338
- <Download size={16} /> Download configuration
339
- </button>
340
- <p>
341
- {saved
342
- ? "Saved hands.json. "
343
- : "Run the configuration on your provisioned compute: "}
344
- <code>uv run lerobot-annotate-hands --config hands.json</code>
345
- </p>
346
- <p className="field-help">
347
- Downloading the configuration starts no compute. Preview with{" "}
348
- <code>--estimate</code> before running; HF Jobs execution is billable.
349
- </p>
350
- </div>
351
- </div>
352
- );
353
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/hands.css DELETED
@@ -1,87 +0,0 @@
1
- .hand-setup .step-card {
2
- margin-bottom: 20px;
3
- }
4
- .hand-setup h2 {
5
- display: flex;
6
- align-items: center;
7
- gap: 9px;
8
- font-size: 16px;
9
- margin: 0 0 14px;
10
- }
11
- .hand-setup p {
12
- color: var(--fg-soft);
13
- line-height: 1.6;
14
- font-size: 14px;
15
- }
16
- .hand-fields {
17
- display: grid;
18
- grid-template-columns: repeat(2, minmax(0, 1fr));
19
- gap: 18px;
20
- margin-top: 22px;
21
- }
22
- .hand-fields label {
23
- display: flex;
24
- flex-direction: column;
25
- gap: 9px;
26
- font-size: 13px;
27
- color: var(--fg-soft);
28
- }
29
- .hand-fields input,
30
- .hand-fields select {
31
- width: 100%;
32
- padding: 11px;
33
- border: 1px solid var(--line-strong);
34
- border-radius: 6px;
35
- background: var(--surface-0);
36
- color: var(--fg);
37
- }
38
- .hand-setup small {
39
- color: var(--fg-muted);
40
- line-height: 1.5;
41
- }
42
- .hand-speed {
43
- border-top: 1px solid var(--line);
44
- margin-top: 24px;
45
- padding-top: 22px;
46
- }
47
- .hand-speed strong {
48
- font-size: 20px;
49
- font-weight: 500;
50
- }
51
- .hand-setup a {
52
- text-decoration: underline;
53
- text-underline-offset: 3px;
54
- }
55
- .hand-import {
56
- display: flex;
57
- align-items: center;
58
- gap: 9px;
59
- font-size: 13px;
60
- flex-wrap: wrap;
61
- }
62
- .hand-import input {
63
- max-width: 100%;
64
- }
65
- .hand-consent {
66
- display: flex;
67
- gap: 10px;
68
- align-items: flex-start;
69
- margin-top: 24px;
70
- font-size: 13px;
71
- line-height: 1.6;
72
- }
73
- .hand-consent input {
74
- margin-top: 4px;
75
- }
76
- .hand-finish {
77
- padding-bottom: 30px;
78
- }
79
- .hand-finish code {
80
- overflow-wrap: anywhere;
81
- font-size: 12px;
82
- }
83
- @media (max-width: 600px) {
84
- .hand-fields {
85
- grid-template-columns: 1fr;
86
- }
87
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
frontend/src/main.tsx CHANGED
@@ -16,6 +16,7 @@ import {
16
  FolderOpen,
17
  Globe,
18
  HelpCircle,
 
19
  Layers3,
20
  ListChecks,
21
  LoaderCircle,
@@ -38,7 +39,11 @@ import "./style.css";
38
  import "./wizard.css";
39
  import AnnotationExamples from "./AnnotationExamples";
40
  import JobsView, { type Job, type Progress } from "./JobsView";
41
- import HandSetupView from "./HandSetupView";
 
 
 
 
42
 
43
  type Feature =
44
  | "Subtasks"
@@ -47,7 +52,9 @@ type Feature =
47
  | "Task rephrasings"
48
  | "Interjections & speech"
49
  | "Visual questions"
50
- | "Human demo videos";
 
 
51
  const HUMAN_VIDEOS: Feature = "Human demo videos";
52
  type Options = {
53
  model: string;
@@ -63,6 +70,7 @@ type Options = {
63
  prompts: Record<string, string>;
64
  human_video_segments: number;
65
  human_video_parallel: number;
 
66
  };
67
  type PromptTemplate = {
68
  key: string;
@@ -122,6 +130,12 @@ type Quote = {
122
  high: number;
123
  inference: number;
124
  helper_required?: boolean;
 
 
 
 
 
 
125
  media?: number;
126
  media_max?: number;
127
  videos?: number;
@@ -170,6 +184,7 @@ const defaults: Options = {
170
  prompts: {},
171
  human_video_segments: 0,
172
  human_video_parallel: 8,
 
173
  };
174
  const definitions: {
175
  name: Feature;
@@ -242,6 +257,12 @@ function initial() {
242
  return {
243
  ...defaults,
244
  ...JSON.parse(localStorage.getItem("studio-draft") || "{}"),
 
 
 
 
 
 
245
  mode: COPY,
246
  };
247
  } catch {
@@ -280,13 +301,14 @@ function reviewDraft(): {
280
 
281
  function App() {
282
  const [modelCatalog, setModelCatalog] = useState<ModelCatalog | null>(null);
283
- const [page, setPage] = useState<"new" | "jobs" | "guide" | "hands">("new");
 
284
  const [options, setOptions] = useState<Options>(initial);
285
  const [savedReview] = useState(reviewDraft);
286
  const [step, setStep] = useState(savedReview.step);
287
  const [furthestStep, setFurthestStep] = useState(savedReview.step);
288
  const stepHeading = useRef<HTMLHeadingElement>(null);
289
- const stepLabels = ["Dataset", "Annotations", "Model", "Review"];
290
  const datasetReady =
291
  /^[^/\s]+\/[^/\s]+$/.test(options.dataset.trim()) &&
292
  Number.isInteger(options.start) &&
@@ -332,9 +354,13 @@ function App() {
332
  (m) => m.key === options.model,
333
  );
334
  const usesFlora = options.captioner === "flora";
 
 
335
  const floraNeedsHelper =
336
  usesFlora &&
337
- (options.features.some((f) => f !== "Subtasks" && f !== "Plans") ||
 
 
338
  !!options.prompts.plan_render);
339
 
340
  async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
@@ -357,6 +383,9 @@ function App() {
357
  return data;
358
  }
359
  useEffect(() => {
 
 
 
360
  api<Session>("/api/session")
361
  .then((s) => {
362
  setSession(s);
@@ -570,7 +599,10 @@ function App() {
570
  features = ["Subtasks", ...features];
571
  if (!on && name === "Subtasks")
572
  features = features.filter((x) => x !== HUMAN_VIDEOS);
573
- change({ features });
 
 
 
574
  }
575
  async function estimate(advance = false) {
576
  setLoading(true);
@@ -598,7 +630,10 @@ function App() {
598
  const canContinueDataset =
599
  datasetReady && !camerasLoading && cameras.includes(options.camera || "");
600
  const canEstimate =
601
- canContinueDataset && options.features.length > 0 && !!selectedModel;
 
 
 
602
  function goToStep(next: number) {
603
  setStep(next);
604
  setFurthestStep((old) => Math.max(old, next));
@@ -732,12 +767,6 @@ function App() {
732
  </nav>
733
 
734
  <nav aria-label="Resources">
735
- <button
736
- className={page === "hands" ? "active" : ""}
737
- onClick={() => navigate("hands")}
738
- >
739
- <Layers3 size={18} /> Human POV hands
740
- </button>
741
  <a
742
  href="https://lerobot-visualize-dataset.hf.space"
743
  target="_blank"
@@ -768,9 +797,7 @@ function App() {
768
  ? "New annotation"
769
  : page === "jobs"
770
  ? "My jobs"
771
- : page === "hands"
772
- ? "Human POV hands"
773
- : "How it works"}
774
  </strong>
775
  </div>
776
  <div className="top-actions">
@@ -863,7 +890,7 @@ function App() {
863
  [
864
  "Choose your dataset",
865
  "What would you like to annotate?",
866
- "Choose your model",
867
  "Review your run",
868
  ][step]
869
  }
@@ -1207,7 +1234,50 @@ function App() {
1207
  )}
1208
  </section>
1209
  )}
1210
- {step === 2 && (
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1211
  <section
1212
  className="card step-card model-picker"
1213
  aria-label="Model settings"
@@ -1417,9 +1487,11 @@ function App() {
1417
  <div>
1418
  <span>Model</span>
1419
  <strong>
1420
- {usesFlora
1421
- ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU"
1422
- : `${selectedModel?.name} · ${selectedModel?.provider_name}`}
 
 
1423
  </strong>
1424
  <button
1425
  className="text-action"
@@ -1458,6 +1530,7 @@ function App() {
1458
  type="radio"
1459
  name="visibility"
1460
  value={value}
 
1461
  checked={visibility === value}
1462
  onChange={() => {
1463
  setVisibility(value);
@@ -1537,6 +1610,14 @@ function App() {
1537
  <span>–</span>
1538
  {money(quote.costs.high)}
1539
  </div>
 
 
 
 
 
 
 
 
1540
  {!!quote.costs.video_minutes &&
1541
  quote.costs.video_minutes >
1542
  quote.costs.hours * 60 && (
@@ -1583,9 +1664,10 @@ function App() {
1583
  )}
1584
  <div>
1585
  <span>
1586
- {options.captioner === "flora"
1587
- ? "H200 GPU"
1588
- : "CPU"}{" "}
 
1589
  job reservation{" "}
1590
  <span
1591
  className="info-tooltip"
@@ -1758,7 +1840,6 @@ function App() {
1758
  </div>
1759
  </div>
1760
  )}
1761
- {page === "hands" && <HandSetupView />}
1762
  {page === "jobs" && (
1763
  <JobsView
1764
  jobs={jobs}
@@ -1981,9 +2062,11 @@ function App() {
1981
  <div>
1982
  <dt>Annotation model</dt>
1983
  <dd>
1984
- {quote?.captioner === "flora"
1985
- ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU · noncommercial research"
1986
- : `${quote?.model_profile.name} · ${quote?.model_profile.provider_name}`}
 
 
1987
  </dd>
1988
  </div>
1989
  {quote?.costs.helper_required && (
 
16
  FolderOpen,
17
  Globe,
18
  HelpCircle,
19
+ Hand,
20
  Layers3,
21
  ListChecks,
22
  LoaderCircle,
 
39
  import "./wizard.css";
40
  import AnnotationExamples from "./AnnotationExamples";
41
  import JobsView, { type Job, type Progress } from "./JobsView";
42
+ import HandActionSettings, {
43
+ defaultHands,
44
+ type HandOptions,
45
+ type HandCatalog,
46
+ } from "./HandActionSettings";
47
 
48
  type Feature =
49
  | "Subtasks"
 
52
  | "Task rephrasings"
53
  | "Interjections & speech"
54
  | "Visual questions"
55
+ | "Human demo videos"
56
+ | "Hand actions";
57
+ const HANDS: Feature = "Hand actions";
58
  const HUMAN_VIDEOS: Feature = "Human demo videos";
59
  type Options = {
60
  model: string;
 
70
  prompts: Record<string, string>;
71
  human_video_segments: number;
72
  human_video_parallel: number;
73
+ hand_actions: HandOptions;
74
  };
75
  type PromptTemplate = {
76
  key: string;
 
130
  high: number;
131
  inference: number;
132
  helper_required?: boolean;
133
+ compute_name?: string;
134
+ hand_timing?: {
135
+ planning_fps: number | null;
136
+ seconds: number | null;
137
+ basis: string;
138
+ };
139
  media?: number;
140
  media_max?: number;
141
  videos?: number;
 
184
  prompts: {},
185
  human_video_segments: 0,
186
  human_video_parallel: 8,
187
+ hand_actions: defaultHands,
188
  };
189
  const definitions: {
190
  name: Feature;
 
257
  return {
258
  ...defaults,
259
  ...JSON.parse(localStorage.getItem("studio-draft") || "{}"),
260
+ hand_actions: {
261
+ ...defaultHands,
262
+ ...JSON.parse(localStorage.getItem("studio-draft") || "{}")
263
+ .hand_actions,
264
+ research_rights: false,
265
+ },
266
  mode: COPY,
267
  };
268
  } catch {
 
301
 
302
  function App() {
303
  const [modelCatalog, setModelCatalog] = useState<ModelCatalog | null>(null);
304
+ const [page, setPage] = useState<"new" | "jobs" | "guide">("new");
305
+ const [handCatalog, setHandCatalog] = useState<HandCatalog | null>(null);
306
  const [options, setOptions] = useState<Options>(initial);
307
  const [savedReview] = useState(reviewDraft);
308
  const [step, setStep] = useState(savedReview.step);
309
  const [furthestStep, setFurthestStep] = useState(savedReview.step);
310
  const stepHeading = useRef<HTMLHeadingElement>(null);
311
+ const stepLabels = ["Dataset", "Annotations", "Models & compute", "Review"];
312
  const datasetReady =
313
  /^[^/\s]+\/[^/\s]+$/.test(options.dataset.trim()) &&
314
  Number.isInteger(options.start) &&
 
354
  (m) => m.key === options.model,
355
  );
356
  const usesFlora = options.captioner === "flora";
357
+ const usesHands = options.features.includes(HANDS);
358
+ const handsOnly = usesHands && options.features.length === 1;
359
  const floraNeedsHelper =
360
  usesFlora &&
361
+ (options.features.some(
362
+ (f) => f !== "Subtasks" && f !== "Plans" && f !== HANDS,
363
+ ) ||
364
  !!options.prompts.plan_render);
365
 
366
  async function api<T>(path: string, init: RequestInit = {}): Promise<T> {
 
383
  return data;
384
  }
385
  useEffect(() => {
386
+ api<HandCatalog>("/api/hand-pipeline")
387
+ .then(setHandCatalog)
388
+ .catch(() => setHandCatalog(null));
389
  api<Session>("/api/session")
390
  .then((s) => {
391
  setSession(s);
 
599
  features = ["Subtasks", ...features];
600
  if (!on && name === "Subtasks")
601
  features = features.filter((x) => x !== HUMAN_VIDEOS);
602
+ if (on && name === HANDS) {
603
+ setVisibility("private");
604
+ change({ features, captioner: "selected-vlm" });
605
+ } else change({ features });
606
  }
607
  async function estimate(advance = false) {
608
  setLoading(true);
 
630
  const canContinueDataset =
631
  datasetReady && !camerasLoading && cameras.includes(options.camera || "");
632
  const canEstimate =
633
+ canContinueDataset &&
634
+ options.features.length > 0 &&
635
+ !!selectedModel &&
636
+ (!usesHands || !!handCatalog?.available);
637
  function goToStep(next: number) {
638
  setStep(next);
639
  setFurthestStep((old) => Math.max(old, next));
 
767
  </nav>
768
 
769
  <nav aria-label="Resources">
 
 
 
 
 
 
770
  <a
771
  href="https://lerobot-visualize-dataset.hf.space"
772
  target="_blank"
 
797
  ? "New annotation"
798
  : page === "jobs"
799
  ? "My jobs"
800
+ : "How it works"}
 
 
801
  </strong>
802
  </div>
803
  <div className="top-actions">
 
890
  [
891
  "Choose your dataset",
892
  "What would you like to annotate?",
893
+ "Choose models & compute",
894
  "Review your run",
895
  ][step]
896
  }
 
1234
  )}
1235
  </section>
1236
  )}
1237
+ {step === 1 && (
1238
+ <section
1239
+ className={`card step-card ${usesHands ? "selected" : ""}`}
1240
+ aria-label="Hand action annotations"
1241
+ >
1242
+ <div className="category-heading">
1243
+ <h3>
1244
+ <Hand size={16} /> Hand actions
1245
+ </h3>
1246
+ <p className="field-help">
1247
+ Reconstruct wrists and fingers in existing human POV
1248
+ recordings.
1249
+ </p>
1250
+ </div>
1251
+ <div className="annotation-choice">
1252
+ <label>
1253
+ <input
1254
+ type="checkbox"
1255
+ checked={usesHands}
1256
+ onChange={() => toggle(HANDS)}
1257
+ />
1258
+ <span>
1259
+ <strong>Hand actions</strong>
1260
+ <small>
1261
+ 102D actions in this dataset, with validity masks.
1262
+ Keeps every frame.
1263
+ </small>
1264
+ </span>
1265
+ </label>
1266
+ </div>
1267
+ <p className="field-help">
1268
+ Uses HF Jobs GPUs, measured camera calibration and your
1269
+ licensed MANO assets. Research use only.
1270
+ </p>
1271
+ </section>
1272
+ )}
1273
+ {step === 2 && usesHands && (
1274
+ <HandActionSettings
1275
+ value={options.hand_actions}
1276
+ catalog={handCatalog}
1277
+ onChange={(hand_actions) => change({ hand_actions })}
1278
+ />
1279
+ )}
1280
+ {step === 2 && !handsOnly && (
1281
  <section
1282
  className="card step-card model-picker"
1283
  aria-label="Model settings"
 
1487
  <div>
1488
  <span>Model</span>
1489
  <strong>
1490
+ {handsOnly
1491
+ ? "HaWoR / MANO · DROID-W · HF Jobs"
1492
+ : usesFlora
1493
+ ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU"
1494
+ : `${selectedModel?.name} · ${selectedModel?.provider_name}`}
1495
  </strong>
1496
  <button
1497
  className="text-action"
 
1530
  type="radio"
1531
  name="visibility"
1532
  value={value}
1533
+ disabled={usesHands && value === "public"}
1534
  checked={visibility === value}
1535
  onChange={() => {
1536
  setVisibility(value);
 
1610
  <span>–</span>
1611
  {money(quote.costs.high)}
1612
  </div>
1613
+ {quote.costs.hand_timing && (
1614
+ <p className="field-help">
1615
+ {quote.costs.hand_timing.seconds
1616
+ ? `~${duration(quote.costs.hand_timing.seconds)} at your ${quote.costs.hand_timing.planning_fps} input FPS assumption.`
1617
+ : "HF GPU throughput is unmeasured; no runtime estimate yet."}{" "}
1618
+ Setup, downloads and dataset copying are additional.
1619
+ </p>
1620
+ )}
1621
  {!!quote.costs.video_minutes &&
1622
  quote.costs.video_minutes >
1623
  quote.costs.hours * 60 && (
 
1664
  )}
1665
  <div>
1666
  <span>
1667
+ {quote?.costs.compute_name ||
1668
+ (options.captioner === "flora"
1669
+ ? "H200 GPU"
1670
+ : "CPU")}{" "}
1671
  job reservation{" "}
1672
  <span
1673
  className="info-tooltip"
 
1840
  </div>
1841
  </div>
1842
  )}
 
1843
  {page === "jobs" && (
1844
  <JobsView
1845
  jobs={jobs}
 
2062
  <div>
2063
  <dt>Annotation model</dt>
2064
  <dd>
2065
+ {handsOnly
2066
+ ? "HaWoR / MANO · DROID-W · HF Jobs"
2067
+ : quote?.captioner === "flora"
2068
+ ? "NVIDIA Flora · TempSeg + Captioner · H200 GPU · noncommercial research"
2069
+ : `${quote?.model_profile.name} · ${quote?.model_profile.provider_name}`}
2070
  </dd>
2071
  </div>
2072
  {quote?.costs.helper_required && (
frontend/src/wizard.css CHANGED
@@ -502,3 +502,35 @@
502
  margin-top: 2px;
503
  }
504
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
502
  margin-top: 2px;
503
  }
504
  }
505
+
506
+ .hand-settings select,
507
+ .hand-settings textarea,
508
+ .hand-settings > input {
509
+ width: 100%;
510
+ border: 1px solid var(--line-strong);
511
+ border-radius: 7px;
512
+ background: var(--surface-0);
513
+ color: var(--fg);
514
+ padding: 10px;
515
+ font: inherit;
516
+ }
517
+ .hand-settings .field-label {
518
+ margin-top: 20px;
519
+ }
520
+ .hand-settings textarea {
521
+ font-family: ui-monospace, monospace;
522
+ font-size: 12px;
523
+ resize: vertical;
524
+ }
525
+ .hand-consent {
526
+ display: flex;
527
+ align-items: flex-start;
528
+ gap: 10px;
529
+ margin-top: 20px;
530
+ font-size: 13px;
531
+ line-height: 1.5;
532
+ }
533
+ .hand-consent input {
534
+ flex: none;
535
+ margin-top: 3px;
536
+ }
hand-pipeline-catalog.json CHANGED
@@ -1,104 +1,36 @@
1
  {
2
  "recipe": "calibrated-droid-hawor-bounded-v1",
3
  "runtime_revision": "d61017dd3a95c59a51d836aab6f7bac1baee34eb",
4
- "reference_url": "https://github.com/pkooij/grounded-data-pipe/blob/d61017dd3a95c59a51d836aab6f7bac1baee34eb/docs/egocentric_bounded_plane.md#fresh-integrated-ten-clip-check",
5
  "profiles": [
6
  {
7
- "key": "rtx-5090",
8
- "name": "RTX 5090",
9
- "hf_flavor": null,
10
- "reference_fps": 53.9
11
- },
12
- {
13
- "key": "a100-80gb",
14
  "name": "A100 80 GB",
15
- "hf_flavor": "a100-large",
16
- "reference_fps": null
17
- },
18
- {
19
- "key": "h100",
20
- "name": "H100 (local / Slurm)",
21
- "hf_flavor": null,
22
  "reference_fps": null
23
  },
24
  {
25
  "key": "rtx-pro-6000",
26
- "name": "RTX PRO 6000",
27
- "hf_flavor": "rtx-pro-6000",
28
  "reference_fps": null
29
  },
30
  {
31
  "key": "h200",
32
- "name": "H200",
33
- "hf_flavor": "h200",
34
  "reference_fps": null
35
  }
36
  ],
37
- "config_template": {
38
- "input_root": "raw-egocentric",
39
- "output_root": "hand-annotations",
40
- "repo_id": "local/human-pov-hands",
41
- "compute": "rtx-5090",
42
  "planning_fps": null,
43
- "videos_per_group": 32,
44
- "minimum_segment_frames": 30,
45
- "backend": {
46
- "root": "",
47
- "python": "",
48
- "macrodata_python": "",
49
- "droid_root": "",
50
- "droid_python": "",
51
- "droid_moge_checkpoint": "",
52
- "droid_model_cache": "",
53
- "assets_manifest": "",
54
- "assets_sha256": "",
55
- "research_terms_accepted": false,
56
- "timeout_s": 7200
57
- },
58
- "runtime": {
59
- "backend": "local",
60
- "mode": "run",
61
- "run_uri": null,
62
- "workers": 1,
63
- "batch_size": 1,
64
- "shard_size": 1,
65
- "max_retries": 0,
66
- "verify_sources": false,
67
- "retain_materialized_outputs": true,
68
- "max_parallel_stages": 1,
69
- "resource_budget": null,
70
- "slurm": {
71
- "partition": null,
72
- "account": null,
73
- "time_limit": "02:00:00",
74
- "max_concurrent": null,
75
- "script_dir": ".lerobot_slurm",
76
- "working_directory": null,
77
- "python_executable": "python",
78
- "sbatch": "sbatch",
79
- "squeue": "squeue",
80
- "requeue": true,
81
- "command_timeout_s": 30.0
82
- },
83
- "hf_jobs": {
84
- "cpu_image": null,
85
- "gpu_image": null,
86
- "cpu_flavor": "cpu-upgrade",
87
- "gpu_flavor": null,
88
- "code_revision": null,
89
- "code_repository": "https://github.com/huggingface/lerobot.git",
90
- "namespace": null,
91
- "bootstrap_packages": [],
92
- "timeout": "2h",
93
- "max_parallel": 4,
94
- "detach": false,
95
- "secret_env": []
96
- }
97
- }
98
  },
99
  "licenses": {
100
  "mano": "https://mano.is.tue.mpg.de/license.html",
101
  "models": "https://huggingface.co/macrodata/egovision-safetensors"
102
  },
103
- "lerobot_revision": "16188355a0b012d6769cc69402a87951524cef5f"
104
  }
 
1
  {
2
  "recipe": "calibrated-droid-hawor-bounded-v1",
3
  "runtime_revision": "d61017dd3a95c59a51d836aab6f7bac1baee34eb",
 
4
  "profiles": [
5
  {
6
+ "key": "a100-large",
 
 
 
 
 
 
7
  "name": "A100 80 GB",
 
 
 
 
 
 
 
8
  "reference_fps": null
9
  },
10
  {
11
  "key": "rtx-pro-6000",
12
+ "name": "RTX PRO 6000 96 GB",
 
13
  "reference_fps": null
14
  },
15
  {
16
  "key": "h200",
17
+ "name": "H200 141 GB",
 
18
  "reference_fps": null
19
  }
20
  ],
21
+ "reference": {
22
+ "frames": 1500,
 
 
 
23
  "planning_fps": null,
24
+ "seconds": null,
25
+ "basis": "HF hardware has not been benchmarked",
26
+ "reference_fps": 53.9,
27
+ "reference_compute": "RTX 5090",
28
+ "reference_scope": "Historical ten-clip calibrated HOT3D check, 1500 source frames; includes model startup, camera/hands and export; excludes installation and transfers.",
29
+ "caveat": "Different HF GPUs have no measured FPS yet. Queue, installation, downloads and dataset copying are additional."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
  },
31
  "licenses": {
32
  "mano": "https://mano.is.tue.mpg.de/license.html",
33
  "models": "https://huggingface.co/macrodata/egovision-safetensors"
34
  },
35
+ "lerobot_revision": "5746c3575818706840d32d99ac28e784742f1373"
36
  }
hand_actions.py ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand actions use the normal annotation wizard and HF Jobs lifecycle."""
2
+
3
+ import json
4
+ import os
5
+ import re
6
+ from dataclasses import asdict
7
+
8
+ from lerobot.annotations.hands.assets import resolve_mano
9
+ from lerobot.annotations.hands.config import COMPUTE, RECIPE, HandBackendConfig, estimate, public_catalog
10
+ from lerobot.annotations.hands.writer import validate_calibration
11
+
12
+ HANDS = "Hand actions"
13
+
14
+
15
+ def runtime_configuration():
16
+ """Read trusted operator provisioning; end users choose HF hardware only."""
17
+ image = os.environ.get("HAND_JOB_IMAGE", "")
18
+ if not re.fullmatch(r"[A-Za-z0-9./:_-]+@sha256:[a-f0-9]{64}", image):
19
+ raise ValueError(
20
+ "Hand actions are not ready to launch: the Space operator must configure its pinned HF Jobs research image"
21
+ )
22
+ backend = HandBackendConfig(**json.loads(os.environ.get("HAND_JOB_BACKEND", "{}")))
23
+ if any(not value for value in asdict(backend).values()):
24
+ raise ValueError("The Space operator must complete the HF Jobs hand runtime configuration")
25
+ return {"image": image, "backend": asdict(backend), "recipe": RECIPE}
26
+
27
+
28
+ def catalog():
29
+ """Expose readiness and hardware without revealing operator paths."""
30
+ result = public_catalog()
31
+ try:
32
+ runtime_configuration()
33
+ result.update(available=True, reason=None)
34
+ except (ValueError, TypeError, json.JSONDecodeError) as exc:
35
+ result.update(available=False, reason=str(exc))
36
+ return result
37
+
38
+
39
+ def prepare_hands(selection, camera, options, token):
40
+ """Bind measured calibration and the user's private assets to the quote."""
41
+ options = options or {}
42
+ compute = options.get("compute", "h200")
43
+ if compute not in COMPUTE:
44
+ raise ValueError("Choose a supported HF Jobs GPU")
45
+ feature = selection["info"]["features"][camera]
46
+ names = feature.get("names") or ["height", "width", "channel"]
47
+ shape = dict(zip(names, feature["shape"], strict=True))
48
+ calibration = options.get("calibration") or feature.get("info", {}).get("camera.intrinsics")
49
+ if isinstance(calibration, str):
50
+ try:
51
+ calibration = json.loads(calibration)
52
+ except json.JSONDecodeError:
53
+ raise ValueError("Camera calibration must be valid JSON") from None
54
+ calibration = validate_calibration(calibration or {}, shape["width"], shape["height"])
55
+ mano = resolve_mano(options.get("mano_repo", ""), token, options.get("research_rights", False))
56
+ if "annotation.original_action" in selection["info"]["features"]:
57
+ raise ValueError(
58
+ "Choose a source dataset that has not already had its actions replaced by hand annotations"
59
+ )
60
+ timing = estimate(sum(r["length"] for r in selection["episodes"]), options.get("planning_fps"))
61
+ return {
62
+ "compute": compute,
63
+ "calibration": calibration,
64
+ "mano": mano,
65
+ "timing": timing,
66
+ "runtime": runtime_configuration(),
67
+ }
pipeline-source.tar.gz CHANGED
@@ -1,3 +1,3 @@
1
  version https://git-lfs.github.com/spec/v1
2
- oid sha256:cd6c90a12ff34d2099c8ae814527d6691fa29a081b09f47825d60cfead9065de
3
- size 1857507
 
1
  version https://git-lfs.github.com/spec/v1
2
+ oid sha256:93558e8f7fe094e5c1b678f7d36ecc34fdbbd94cd812536c577aa6e5c7c765cd
3
+ size 1854542
pipeline-version.json CHANGED
@@ -1,6 +1,6 @@
1
  {
2
- "git_commit": "16188355a0b012d6769cc69402a87951524cef5f",
3
- "sha256": "cd6c90a12ff34d2099c8ae814527d6691fa29a081b09f47825d60cfead9065de",
4
  "preset": "abc_phases_schema_with_original_window_recovery",
5
  "preset_date": "2026-09-25",
6
  "engine": "lerobot.annotations.runtime",
 
1
  {
2
+ "git_commit": "5746c3575818706840d32d99ac28e784742f1373",
3
+ "sha256": "93558e8f7fe094e5c1b678f7d36ecc34fdbbd94cd812536c577aa6e5c7c765cd",
4
  "preset": "abc_phases_schema_with_original_window_recovery",
5
  "preset_date": "2026-09-25",
6
  "engine": "lerobot.annotations.runtime",
requirements.txt CHANGED
@@ -2,4 +2,5 @@ fastapi==0.141.1
2
  uvicorn==0.54.0
3
  huggingface_hub==1.26.0
4
  pyarrow==24.0.0
 
5
  requests==2.34.2
 
2
  uvicorn==0.54.0
3
  huggingface_hub==1.26.0
4
  pyarrow==24.0.0
5
+ numpy==2.2.6
6
  requests==2.34.2
server.py CHANGED
@@ -17,6 +17,7 @@ import requests
17
  from fastapi import FastAPI, HTTPException, Request
18
  from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
19
  from fastapi.staticfiles import StaticFiles
 
20
  from huggingface_hub import HfApi, hf_hub_download
21
  from model_catalog import DEFAULT_MODEL, public_models
22
  from prompt_config import public_catalog, validate_prompts
@@ -40,12 +41,21 @@ class DatasetInput(BaseModel):
40
  dataset: str = Field(min_length=3, max_length=200)
41
 
42
 
 
 
 
 
 
 
 
 
43
  class EstimateInput(DatasetInput):
44
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
45
  captioner: Literal["selected-vlm", "flora"] = "selected-vlm"
46
  start: int = Field(default=0, ge=0)
47
  count: int = Field(default=100, ge=0)
48
- features: list[str] = Field(min_length=1, max_length=7)
 
49
  camera: str | None = Field(default=None, max_length=200)
50
  hz: float = 0.1
51
  hours: int = 4
@@ -273,6 +283,7 @@ def calculate(options: EstimateInput, request: Request):
273
  human_video_segments=options.human_video_segments,
274
  human_video_parallel=options.human_video_parallel,
275
  captioner=options.captioner,
 
276
  )
277
  except Exception as exc:
278
  raise HTTPException(400, safe_error(exc)) from None
@@ -504,7 +515,7 @@ def health():
504
 
505
  @app.get("/api/hand-pipeline")
506
  def hand_pipeline_catalog():
507
- return json.loads((BASE / "hand-pipeline-catalog.json").read_text())
508
 
509
 
510
  @app.get("/api/models")
 
17
  from fastapi import FastAPI, HTTPException, Request
18
  from fastapi.responses import FileResponse, JSONResponse, RedirectResponse, Response
19
  from fastapi.staticfiles import StaticFiles
20
+ from hand_actions import catalog as hand_catalog
21
  from huggingface_hub import HfApi, hf_hub_download
22
  from model_catalog import DEFAULT_MODEL, public_models
23
  from prompt_config import public_catalog, validate_prompts
 
41
  dataset: str = Field(min_length=3, max_length=200)
42
 
43
 
44
+ class HandActionInput(BaseModel):
45
+ compute: Literal["h200", "a100-large", "rtx-pro-6000"] = "h200"
46
+ planning_fps: float | None = Field(default=None, gt=0, le=100000, allow_inf_nan=False)
47
+ calibration: str = Field(default="", max_length=12000)
48
+ mano_repo: str = Field(default="", max_length=200)
49
+ research_rights: bool = False
50
+
51
+
52
  class EstimateInput(DatasetInput):
53
  model: str = Field(default=DEFAULT_MODEL, min_length=1, max_length=80)
54
  captioner: Literal["selected-vlm", "flora"] = "selected-vlm"
55
  start: int = Field(default=0, ge=0)
56
  count: int = Field(default=100, ge=0)
57
+ features: list[str] = Field(min_length=1, max_length=8)
58
+ hand_actions: HandActionInput | None = None
59
  camera: str | None = Field(default=None, max_length=200)
60
  hz: float = 0.1
61
  hours: int = 4
 
283
  human_video_segments=options.human_video_segments,
284
  human_video_parallel=options.human_video_parallel,
285
  captioner=options.captioner,
286
+ hand_actions=options.hand_actions.model_dump() if options.hand_actions else None,
287
  )
288
  except Exception as exc:
289
  raise HTTPException(400, safe_error(exc)) from None
 
515
 
516
  @app.get("/api/hand-pipeline")
517
  def hand_pipeline_catalog():
518
+ return hand_catalog()
519
 
520
 
521
  @app.get("/api/models")
studio.py CHANGED
@@ -13,6 +13,8 @@ from pathlib import Path, PurePosixPath
13
 
14
  import pyarrow.parquet as pq
15
  from flora_captioner import FLORA
 
 
16
  from huggingface_hub import HfApi, hf_hub_download
17
  from huggingface_hub.utils import validate_repo_id
18
  from model_catalog import (
@@ -26,6 +28,8 @@ from model_catalog import (
26
  )
27
  from prompt_config import TEMPLATES, validate_prompts
28
 
 
 
29
  MODEL = model_profile()["route"]
30
  PROVIDER = model_profile()["provider"]
31
  FEATURES = {
@@ -37,6 +41,7 @@ FEATURES = {
37
  "Visual questions": "vqa",
38
  # Not a language style: videos are saved under annotation_studio/human_videos/.
39
  "Human demo videos": "human_video",
 
40
  }
41
  HUMAN_VIDEOS = "Human demo videos"
42
  COPY = "Annotated dataset copy"
@@ -44,7 +49,10 @@ EXPORT = "Annotation files only" # Historical reports only; new runs always wri
44
 
45
 
46
  def needs_provider(features, prompts=None, captioner="selected-vlm"):
47
- """Flora supplies its own boundaries and captions; default plans are deterministic."""
 
 
 
48
  return (
49
  captioner != "flora"
50
  or bool(set(features) - {"Subtasks", "Plans"})
@@ -200,7 +208,7 @@ def estimate(
200
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
201
  raise ValueError("Choose a supported timeout and visual-question frequency.")
202
  n, seconds = len(selection["episodes"]), selection["seconds"]
203
- needs_subtasks = bool(set(features) - {"Visual questions"})
204
  if captioner == "flora" and not needs_subtasks:
205
  raise ValueError("Flora needs an annotation type that uses subtasks.")
206
  if captioner == "flora" and "plan_subtasks" in prompts:
@@ -359,6 +367,7 @@ def prepare(
359
  human_video_segments=None,
360
  human_video_parallel=None,
361
  captioner="selected-vlm",
 
362
  ):
363
  profile = model_profile(model_key)
364
  prompts = validate_prompts(prompts or {}, features)
@@ -373,7 +382,10 @@ def prepare(
373
  raise ValueError("Choose a camera in this dataset.")
374
  if captioner not in {"selected-vlm", "flora"}:
375
  raise ValueError("Choose a supported subtask captioner.")
376
- flavor = FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
 
 
 
377
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == flavor)
378
  if hardware.unit_label != "minute":
379
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
@@ -389,6 +401,8 @@ def prepare(
389
  human_video_parallel,
390
  captioner,
391
  )
 
 
392
  return {
393
  "selection": selection,
394
  "features": list(features),
@@ -400,6 +414,7 @@ def prepare(
400
  "model_profile": profile,
401
  "captioner": captioner,
402
  "captioner_profile": dict(FLORA) if captioner == "flora" else None,
 
403
  "human_video": {
404
  # 0 = every subtask.
405
  "segments_per_episode": human_video_segments,
@@ -437,7 +452,7 @@ def provider_live(api, model_id, provider):
437
  return hosted is not None and hosted.status == "live"
438
 
439
 
440
- def job_command(space, revision, timeout_minutes=None, captioner="selected-vlm"):
441
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
442
  validate_repo_id(space)
443
  if not re.fullmatch(r"[a-f0-9]{40}", revision):
@@ -462,7 +477,7 @@ def job_command(space, revision, timeout_minutes=None, captioner="selected-vlm")
462
  "pip install --quiet huggingface_hub==1.26.0",
463
  "python -c " + shlex.quote(download),
464
  "pip install --quiet torch==2.11.0 torchvision==0.26.0 --index-url https://download.pytorch.org/whl/"
465
- + ("cu128" if captioner == "flora" else "cpu"),
466
  "pip install --quiet -r /workspace/app/worker-requirements.txt",
467
  "pip install --quiet -r /workspace/app/flora-requirements.txt"
468
  if captioner == "flora"
@@ -488,6 +503,11 @@ def launch(
488
  ):
489
  if visibility not in ("private", "public"):
490
  raise ValueError("Choose private or public output visibility.")
 
 
 
 
 
491
  if timeout_minutes is not None and (
492
  type(timeout_minutes) is not int or not 1 <= timeout_minutes <= plan["costs"]["hours"] * 60
493
  ):
@@ -528,7 +548,7 @@ def launch(
528
  raise ValueError("Choose a supported subtask captioner.")
529
  if captioner == "flora" and plan.get("captioner_profile") != FLORA:
530
  raise ValueError("Flora configuration changed. Calculate a fresh estimate.")
531
- flavor = FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
532
  hardware = next(h for h in api.list_jobs_hardware() if h.name == flavor)
533
  if hardware.unit_label != "minute" or hardware.unit_cost_usd > plan["costs"]["cpu_rate"]:
534
  raise ValueError("Compute pricing changed. Calculate a fresh estimate.")
@@ -590,25 +610,50 @@ def launch(
590
  "count": plan["selection"]["count"],
591
  "budget": budget,
592
  "inference_budget": budget - plan["costs"]["cpu_max"],
593
- "model": profile["route"],
594
  "provider_required": uses_provider,
595
- "subtask_models": [FLORA["segmenter_id"], FLORA["model_id"]]
 
 
596
  if captioner == "flora"
597
  else [profile["model_id"]],
598
  "helper_model": profile["route"] if captioner == "flora" and uses_provider else None,
599
  "model_profile": profile,
600
  "generation_options": profile["generation_options"],
601
  "request_concurrency": profile["concurrency"],
602
- "episode_parallelism": 1 if captioner == "flora" else profile["concurrency"],
603
  "space": space,
604
  "space_revision": revision,
605
  "owner": username,
606
  "runtime_limit_minutes": timeout_minutes,
607
  }
608
  )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
609
  job = api.run_job(
610
- image="python:3.12-slim",
611
- command=job_command(space, revision, timeout_minutes, captioner),
612
  flavor=flavor,
613
  timeout=f"{timeout_minutes}m" if timeout_minutes else f"{plan['costs']['hours']}h",
614
  namespace=username,
@@ -618,7 +663,7 @@ def launch(
618
  "app": "lerobot-annotation-studio",
619
  "annotation_output": output.split("/", 1)[1],
620
  },
621
- env={"ANNOTATION_MANIFEST": json.dumps(manifest)},
622
  secrets={"HF_TOKEN": token},
623
  )
624
  return {"id": job.id, "owner": username, "output": output}
 
13
 
14
  import pyarrow.parquet as pq
15
  from flora_captioner import FLORA
16
+ from flora_image import validated_worker_image
17
+ from hand_actions import HANDS, prepare_hands, runtime_configuration
18
  from huggingface_hub import HfApi, hf_hub_download
19
  from huggingface_hub.utils import validate_repo_id
20
  from model_catalog import (
 
28
  )
29
  from prompt_config import TEMPLATES, validate_prompts
30
 
31
+ from lerobot.annotations.hands.config import COMPUTE
32
+
33
  MODEL = model_profile()["route"]
34
  PROVIDER = model_profile()["provider"]
35
  FEATURES = {
 
41
  "Visual questions": "vqa",
42
  # Not a language style: videos are saved under annotation_studio/human_videos/.
43
  "Human demo videos": "human_video",
44
+ HANDS: "hands",
45
  }
46
  HUMAN_VIDEOS = "Human demo videos"
47
  COPY = "Annotated dataset copy"
 
49
 
50
 
51
  def needs_provider(features, prompts=None, captioner="selected-vlm"):
52
+ """Native hand-only and Flora workflows never need a hosted helper."""
53
+ features = set(features) - {HANDS}
54
+ if not features:
55
+ return False
56
  return (
57
  captioner != "flora"
58
  or bool(set(features) - {"Subtasks", "Plans"})
 
208
  if hours not in (1, 4, 12) or vqa_hz not in (0.1, 0.5, 1.0):
209
  raise ValueError("Choose a supported timeout and visual-question frequency.")
210
  n, seconds = len(selection["episodes"]), selection["seconds"]
211
+ needs_subtasks = bool(set(features) - {"Visual questions", HANDS})
212
  if captioner == "flora" and not needs_subtasks:
213
  raise ValueError("Flora needs an annotation type that uses subtasks.")
214
  if captioner == "flora" and "plan_subtasks" in prompts:
 
367
  human_video_segments=None,
368
  human_video_parallel=None,
369
  captioner="selected-vlm",
370
+ hand_actions=None,
371
  ):
372
  profile = model_profile(model_key)
373
  prompts = validate_prompts(prompts or {}, features)
 
382
  raise ValueError("Choose a camera in this dataset.")
383
  if captioner not in {"selected-vlm", "flora"}:
384
  raise ValueError("Choose a supported subtask captioner.")
385
+ hands = prepare_hands(selection, camera, hand_actions, token) if HANDS in features else None
386
+ if hands and captioner == "flora":
387
+ raise ValueError("Run Hand actions and Flora separately so each model has the GPU memory it requires")
388
+ flavor = hands["compute"] if hands else FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
389
  hardware = next(h for h in HfApi(token=token or False).list_jobs_hardware() if h.name == flavor)
390
  if hardware.unit_label != "minute":
391
  raise ValueError("HF changed its billing units; refresh the Space before launching.")
 
401
  human_video_parallel,
402
  captioner,
403
  )
404
+ if hands:
405
+ costs.update(compute_flavor=flavor, compute_name=COMPUTE[flavor], hand_timing=hands["timing"])
406
  return {
407
  "selection": selection,
408
  "features": list(features),
 
414
  "model_profile": profile,
415
  "captioner": captioner,
416
  "captioner_profile": dict(FLORA) if captioner == "flora" else None,
417
+ "hand_actions": hands,
418
  "human_video": {
419
  # 0 = every subtask.
420
  "segments_per_episode": human_video_segments,
 
452
  return hosted is not None and hosted.status == "live"
453
 
454
 
455
+ def job_command(space, revision, timeout_minutes=None, captioner="selected-vlm", hands=False):
456
  # Only trusted deployment values enter shell syntax. User options travel in JSON env.
457
  validate_repo_id(space)
458
  if not re.fullmatch(r"[a-f0-9]{40}", revision):
 
477
  "pip install --quiet huggingface_hub==1.26.0",
478
  "python -c " + shlex.quote(download),
479
  "pip install --quiet torch==2.11.0 torchvision==0.26.0 --index-url https://download.pytorch.org/whl/"
480
+ + ("cu128" if captioner == "flora" or hands else "cpu"),
481
  "pip install --quiet -r /workspace/app/worker-requirements.txt",
482
  "pip install --quiet -r /workspace/app/flora-requirements.txt"
483
  if captioner == "flora"
 
503
  ):
504
  if visibility not in ("private", "public"):
505
  raise ValueError("Choose private or public output visibility.")
506
+ hands = plan.get("hand_actions") if plan else None
507
+ if hands and visibility != "private":
508
+ raise ValueError("Hand research outputs must be private")
509
+ if hands and hands["runtime"] != runtime_configuration():
510
+ raise ValueError("The hand runtime changed; calculate a fresh estimate")
511
  if timeout_minutes is not None and (
512
  type(timeout_minutes) is not int or not 1 <= timeout_minutes <= plan["costs"]["hours"] * 60
513
  ):
 
548
  raise ValueError("Choose a supported subtask captioner.")
549
  if captioner == "flora" and plan.get("captioner_profile") != FLORA:
550
  raise ValueError("Flora configuration changed. Calculate a fresh estimate.")
551
+ flavor = hands["compute"] if hands else FLORA["hardware"] if captioner == "flora" else "cpu-upgrade"
552
  hardware = next(h for h in api.list_jobs_hardware() if h.name == flavor)
553
  if hardware.unit_label != "minute" or hardware.unit_cost_usd > plan["costs"]["cpu_rate"]:
554
  raise ValueError("Compute pricing changed. Calculate a fresh estimate.")
 
610
  "count": plan["selection"]["count"],
611
  "budget": budget,
612
  "inference_budget": budget - plan["costs"]["cpu_max"],
613
+ "model": "HaWoR/MANO + DROID-W" if set(plan["features"]) == {HANDS} else profile["route"],
614
  "provider_required": uses_provider,
615
+ "subtask_models": []
616
+ if set(plan["features"]) == {HANDS}
617
+ else [FLORA["segmenter_id"], FLORA["model_id"]]
618
  if captioner == "flora"
619
  else [profile["model_id"]],
620
  "helper_model": profile["route"] if captioner == "flora" and uses_provider else None,
621
  "model_profile": profile,
622
  "generation_options": profile["generation_options"],
623
  "request_concurrency": profile["concurrency"],
624
+ "episode_parallelism": 1 if captioner == "flora" or hands else profile["concurrency"],
625
  "space": space,
626
  "space_revision": revision,
627
  "owner": username,
628
  "runtime_limit_minutes": timeout_minutes,
629
  }
630
  )
631
+ image, command = "python:3.12-slim", job_command(space, revision, timeout_minutes, captioner)
632
+ environment = {"ANNOTATION_MANIFEST": json.dumps(manifest)}
633
+ accelerated = validated_worker_image(captioner, plan["features"])
634
+ if accelerated:
635
+ image_info = api.space_info(accelerated["repo"])
636
+ if image_info.sha != accelerated["revision"]:
637
+ raise ValueError("Accelerated Flora image changed; wait for a validated release")
638
+ image = f"hf.co/spaces/{accelerated['repo']}"
639
+ command = ["python", "/workspace/app/flora_speed_entry.py"]
640
+ manifest["worker_image"] = {key: accelerated[key] for key in ("repo", "revision")}
641
+ environment.update(
642
+ ANNOTATION_MANIFEST=json.dumps(manifest),
643
+ FLORA_EXECUTION="accelerated",
644
+ FLORA_BATCH_SIZE=str(accelerated.get("batch_size", 4)),
645
+ FLORA_ENABLE_INFERENCE="1",
646
+ FLORA_EXPECTED_CODE=json.dumps(accelerated["expected_code"]),
647
+ FLORA_RUNTIME_SECONDS=str(
648
+ int(timeout_minutes * 60 if timeout_minutes else plan["costs"]["hours"] * 3600)
649
+ ),
650
+ )
651
+ if hands:
652
+ image = hands["runtime"]["image"]
653
+ command = job_command(space, revision, timeout_minutes, captioner, hands=True)
654
  job = api.run_job(
655
+ image=image,
656
+ command=command,
657
  flavor=flavor,
658
  timeout=f"{timeout_minutes}m" if timeout_minutes else f"{plan['costs']['hours']}h",
659
  namespace=username,
 
663
  "app": "lerobot-annotation-studio",
664
  "annotation_output": output.split("/", 1)[1],
665
  },
666
+ env=environment,
667
  secrets={"HF_TOKEN": token},
668
  )
669
  return {"id": job.id, "owner": username, "output": output}
studio_runtime.py CHANGED
@@ -72,11 +72,42 @@ class StudioStage:
72
  else:
73
  artifact = Artifact(**item.payload["upstream"])
74
  if not context.store.verify(artifact):
75
- raise ValueError("Language checkpoint checksum mismatch")
76
  bundle = context.store.download(artifact, directory / "language.tar.gz")
77
  unpack(bundle, directory)
78
  bundle.unlink()
79
  result = json.loads((directory / "result.json").read_text())
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
80
  staging = EpisodeStaging(staging_dir, record.episode_index)
81
  frames = worker.SourceFrames(row, info, root, manifest["camera"])
82
  full = worker.SourceFrames(row, info, root, manifest["camera"], max_size=None)
@@ -136,7 +167,7 @@ class EpisodeSource:
136
  payload = {}
137
  identity = dict(self.identity)
138
  if upstream:
139
- plan, _ = upstream["language"]
140
  _, result = next(accepted_items(store, plan))
141
  artifact = result.artifacts[0]
142
  payload["upstream"] = asdict(artifact)
@@ -162,6 +193,7 @@ def run_episode(record, row, info, root, manifest, provider, staging_dir, media=
162
  "captioner",
163
  "captioner_profile",
164
  "flora_execution",
 
165
  )
166
  },
167
  "code": {
@@ -187,6 +219,15 @@ def run_episode(record, row, info, root, manifest, provider, staging_dir, media=
187
  ("language",),
188
  )
189
  )
 
 
 
 
 
 
 
 
 
190
  # Existing Studio threads own concurrency; one in-process runtime worker
191
  # shares the same request budget. Accepted media is never automatically rebilled.
192
  runtime = RuntimeConfig(
@@ -205,6 +246,10 @@ def run_episode(record, row, info, root, manifest, provider, staging_dir, media=
205
  bundle = store.download(result.artifacts[0], directory / "result.tar.gz")
206
  unpack(bundle, directory)
207
  result = json.loads((directory / "result.json").read_text())
 
 
 
 
208
  if (directory / "staging").exists():
209
  shutil.rmtree(staging_dir / f"episode_{record.episode_index:06d}", ignore_errors=True)
210
  shutil.copytree(directory / "staging", staging_dir, dirs_exist_ok=True)
 
72
  else:
73
  artifact = Artifact(**item.payload["upstream"])
74
  if not context.store.verify(artifact):
75
+ raise ValueError("Upstream checkpoint checksum mismatch")
76
  bundle = context.store.download(artifact, directory / "language.tar.gz")
77
  unpack(bundle, directory)
78
  bundle.unlink()
79
  result = json.loads((directory / "result.json").read_text())
80
+ if self.phase == "hands":
81
+ import pyarrow.parquet as pq
82
+ from subset import trim_video
83
+
84
+ from lerobot.annotations.hands.writer import attach_actions
85
+
86
+ if worker.HAND_ANNOTATOR is None:
87
+ raise RuntimeError("The HF Jobs hand worker was not initialized")
88
+ native = directory / "hand-native"
89
+ native.mkdir(parents=True, exist_ok=True)
90
+ video = native / "episode.mp4"
91
+ trim_video(
92
+ root / worker.video_path(row, info, manifest["camera"]),
93
+ video,
94
+ float(row[f"videos/{manifest['camera']}/from_timestamp"]),
95
+ int(row["length"]),
96
+ float(info["fps"]),
97
+ lossless=True,
98
+ )
99
+ native_report = worker.HAND_ANNOTATOR.annotate(
100
+ video, manifest["hand_actions"]["calibration"], native / "work"
101
+ )
102
+ table = pq.read_table(
103
+ record.data_path, filters=[("episode_index", "=", record.episode_index)]
104
+ )
105
+ table, summary = attach_actions(table, native_report, directory=native, fps=info["fps"])
106
+ pq.write_table(table, directory / "hand_actions.parquet")
107
+ result["hand_actions"] = summary
108
+ # Model assets and RGB/prediction caches never enter dataset uploads or checkpoints.
109
+ shutil.rmtree(native)
110
+ elif self.phase == "human_video":
111
  staging = EpisodeStaging(staging_dir, record.episode_index)
112
  frames = worker.SourceFrames(row, info, root, manifest["camera"])
113
  full = worker.SourceFrames(row, info, root, manifest["camera"], max_size=None)
 
167
  payload = {}
168
  identity = dict(self.identity)
169
  if upstream:
170
+ plan, _ = next(iter(upstream.values()))
171
  _, result = next(accepted_items(store, plan))
172
  artifact = result.artifacts[0]
173
  payload["upstream"] = asdict(artifact)
 
193
  "captioner",
194
  "captioner_profile",
195
  "flora_execution",
196
+ "hand_actions",
197
  )
198
  },
199
  "code": {
 
219
  ("language",),
220
  )
221
  )
222
+ if manifest.get("hand_actions"):
223
+ stages.append(
224
+ StageConfig(
225
+ "hands",
226
+ "studio_runtime:StudioStage",
227
+ {"handle": handle, "phase": "hands", "scientific": scientific},
228
+ (stages[-1].id,),
229
+ )
230
+ )
231
  # Existing Studio threads own concurrency; one in-process runtime worker
232
  # shares the same request budget. Accepted media is never automatically rebilled.
233
  runtime = RuntimeConfig(
 
246
  bundle = store.download(result.artifacts[0], directory / "result.tar.gz")
247
  unpack(bundle, directory)
248
  result = json.loads((directory / "result.json").read_text())
249
+ if (directory / "hand_actions.parquet").exists():
250
+ target = staging_dir.parent / "hand_actions" / f"episode_{record.episode_index:06d}.parquet"
251
+ target.parent.mkdir(parents=True, exist_ok=True)
252
+ shutil.copyfile(directory / "hand_actions.parquet", target)
253
  if (directory / "staging").exists():
254
  shutil.rmtree(staging_dir / f"episode_{record.episode_index:06d}", ignore_errors=True)
255
  shutil.copytree(directory / "staging", staging_dir, dirs_exist_ok=True)
subset.py CHANGED
@@ -1,6 +1,7 @@
1
  """Build a self-contained v3 dataset from completed annotations, one episode at a time."""
2
 
3
  import json
 
4
  from copy import deepcopy
5
  from fractions import Fraction
6
  from pathlib import Path
@@ -21,16 +22,20 @@ VIDEO_PATH = "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.m
21
  EPISODES_PATH = "meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet"
22
 
23
 
24
- def trim_video(source, destination, offset, length, fps):
25
  """Seek to an episode and encode exactly its frames, with timestamps starting at zero."""
26
  destination.parent.mkdir(parents=True, exist_ok=True)
27
  rate = Fraction(str(fps)).limit_denominator(100000)
28
  with av.open(str(source)) as src, av.open(str(destination), "w") as dst:
29
  stream = src.streams.video[0]
30
  stream.codec_context.thread_count = 2
31
- output = dst.add_stream("libx264", rate=rate, options={"crf": "18", "preset": "fast", "g": "30"})
 
 
 
 
32
  output.width, output.height = stream.codec_context.width, stream.codec_context.height
33
- output.pix_fmt = "yuv420p"
34
  output.codec_context.thread_count = 2
35
  output.time_base = 1 / rate
36
  src.seek(int(offset / stream.time_base), stream=stream, backward=True)
@@ -43,7 +48,7 @@ def trim_video(source, destination, offset, length, fps):
43
  continue
44
  if abs(timestamp - (offset + count / fps)) > 0.51 / fps:
45
  raise ValueError("Source video does not match the episode's frame timestamps.")
46
- encoded = frame.reformat(format="yuv420p")
47
  encoded.pts, encoded.time_base = count, 1 / rate
48
  for packet in output.encode(encoded):
49
  dst.mux(packet)
@@ -63,7 +68,10 @@ def trim_video(source, destination, offset, length, fps):
63
  class EpisodeSubset:
64
  """Only completed episodes enter this writer. Upload each returned file set atomically."""
65
 
66
- def __init__(self, source_root, root, source_ids):
 
 
 
67
  self.source_root, self.root = Path(source_root), Path(root)
68
  self.root.mkdir(parents=True, exist_ok=True)
69
  self.info = json.loads((self.source_root / "meta/info.json").read_text())
@@ -95,7 +103,7 @@ class EpisodeSubset:
95
  }
96
  )
97
 
98
- def add(self, source_id, table):
99
  """Materialize an annotated episode, retaining all its cameras and existing other styles."""
100
  if any(m["source_episode_index"] == source_id for m in self.mapping):
101
  raise ValueError("Episode was already added to the subset.")
@@ -104,6 +112,25 @@ class EpisodeSubset:
104
  length = int(row["length"])
105
  if len(table) != length or table["frame_index"].to_pylist() != list(range(length)):
106
  raise ValueError("Episode data length or frame order does not match its metadata.")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
107
  new_id = len(self.mapping)
108
  chunk, file = divmod(new_id, 1000)
109
  changes = {
@@ -136,7 +163,8 @@ class EpisodeSubset:
136
  "meta/episodes/chunk_index": chunk,
137
  "meta/episodes/file_index": file,
138
  }
139
- for camera in self.cameras:
 
140
  source_video = self.source_root / relative_path(
141
  self.source_info["video_path"].format(
142
  video_key=camera,
@@ -154,6 +182,14 @@ class EpisodeSubset:
154
  length,
155
  float(self.info["fps"]),
156
  )
 
 
 
 
 
 
 
 
157
  paths.append(target)
158
  episode.update(
159
  {
@@ -176,6 +212,11 @@ class EpisodeSubset:
176
  episode_stats.setdefault(feature, {})[stat] = np.atleast_1d(np.asarray(value))
177
  for feature in changes:
178
  episode_stats[feature] = get_feature_stats(np.asarray(changes[feature]), axis=0, keepdims=True)
 
 
 
 
 
179
  for feature, stats in episode_stats.items():
180
  for stat, value in stats.items():
181
  episode[f"stats/{feature}/{stat}"] = value.tolist()
 
1
  """Build a self-contained v3 dataset from completed annotations, one episode at a time."""
2
 
3
  import json
4
+ from concurrent.futures import ThreadPoolExecutor
5
  from copy import deepcopy
6
  from fractions import Fraction
7
  from pathlib import Path
 
22
  EPISODES_PATH = "meta/episodes/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet"
23
 
24
 
25
+ def trim_video(source, destination, offset, length, fps, *, lossless=False):
26
  """Seek to an episode and encode exactly its frames, with timestamps starting at zero."""
27
  destination.parent.mkdir(parents=True, exist_ok=True)
28
  rate = Fraction(str(fps)).limit_denominator(100000)
29
  with av.open(str(source)) as src, av.open(str(destination), "w") as dst:
30
  stream = src.streams.video[0]
31
  stream.codec_context.thread_count = 2
32
+ output = dst.add_stream(
33
+ "libx264rgb" if lossless else "libx264",
34
+ rate=rate,
35
+ options={"crf": "0" if lossless else "18", "preset": "fast", "g": "30"},
36
+ )
37
  output.width, output.height = stream.codec_context.width, stream.codec_context.height
38
+ output.pix_fmt = "rgb24" if lossless else "yuv420p"
39
  output.codec_context.thread_count = 2
40
  output.time_base = 1 / rate
41
  src.seek(int(offset / stream.time_base), stream=stream, backward=True)
 
48
  continue
49
  if abs(timestamp - (offset + count / fps)) > 0.51 / fps:
50
  raise ValueError("Source video does not match the episode's frame timestamps.")
51
+ encoded = frame.reformat(format=output.pix_fmt)
52
  encoded.pts, encoded.time_base = count, 1 / rate
53
  for packet in output.encode(encoded):
54
  dst.mux(packet)
 
68
  class EpisodeSubset:
69
  """Only completed episodes enter this writer. Upload each returned file set atomically."""
70
 
71
+ def __init__(self, source_root, root, source_ids, camera_workers=1):
72
+ if camera_workers not in (1, 2, 3):
73
+ raise ValueError("Export supports one to three camera workers")
74
+ self.camera_workers = camera_workers
75
  self.source_root, self.root = Path(source_root), Path(root)
76
  self.root.mkdir(parents=True, exist_ok=True)
77
  self.info = json.loads((self.source_root / "meta/info.json").read_text())
 
103
  }
104
  )
105
 
106
+ def add(self, source_id, table, *, hand_features=None):
107
  """Materialize an annotated episode, retaining all its cameras and existing other styles."""
108
  if any(m["source_episode_index"] == source_id for m in self.mapping):
109
  raise ValueError("Episode was already added to the subset.")
 
112
  length = int(row["length"])
113
  if len(table) != length or table["frame_index"].to_pylist() != list(range(length)):
114
  raise ValueError("Episode data length or frame order does not match its metadata.")
115
+ if hand_features:
116
+ description = {
117
+ "coordinates": "fixed segment-start FLU (X forward, Y left, Z up)",
118
+ "wrist_position_units": "inferred metres; monocular metric scale is model-dependent",
119
+ "rotation_units": "axis-angle radians",
120
+ "validity_feature": "annotation.hand_action_valid",
121
+ "observed_training_feature": "annotation.hand_action_observed_valid",
122
+ "infilling_feature": "annotation.hand_infilled",
123
+ "reference_feature": "annotation.hand_reference_frame",
124
+ "invalid_action": "zero placeholder; must be masked during training",
125
+ "minimum_segment_frames": 30,
126
+ }
127
+ for name, feature in hand_features.items():
128
+ self.info["features"][name] = deepcopy(feature)
129
+ self.info["features"]["action"]["info"] = description
130
+ if "action" in self.source_info["features"]:
131
+ self.info["features"]["annotation.original_action"] = deepcopy(
132
+ self.source_info["features"]["action"]
133
+ )
134
  new_id = len(self.mapping)
135
  chunk, file = divmod(new_id, 1000)
136
  changes = {
 
163
  "meta/episodes/chunk_index": chunk,
164
  "meta/episodes/file_index": file,
165
  }
166
+
167
+ def trim_camera(camera):
168
  source_video = self.source_root / relative_path(
169
  self.source_info["video_path"].format(
170
  video_key=camera,
 
182
  length,
183
  float(self.info["fps"]),
184
  )
185
+ return target
186
+
187
+ if self.camera_workers > 1:
188
+ with ThreadPoolExecutor(max_workers=min(self.camera_workers, len(self.cameras))) as pool:
189
+ targets = list(pool.map(trim_camera, self.cameras))
190
+ else:
191
+ targets = [trim_camera(camera) for camera in self.cameras]
192
+ for camera, target in zip(self.cameras, targets, strict=True):
193
  paths.append(target)
194
  episode.update(
195
  {
 
212
  episode_stats.setdefault(feature, {})[stat] = np.atleast_1d(np.asarray(value))
213
  for feature in changes:
214
  episode_stats[feature] = get_feature_stats(np.asarray(changes[feature]), axis=0, keepdims=True)
215
+ if hand_features:
216
+ for feature in [*hand_features, "annotation.original_action"]:
217
+ if feature in table.column_names:
218
+ values = np.asarray(table[feature].to_pylist())
219
+ episode_stats[feature] = get_feature_stats(values, axis=0, keepdims=values.ndim == 1)
220
  for feature, stats in episode_stats.items():
221
  for stat, value in stats.items():
222
  episode[f"stats/{feature}/{stat}"] = value.tolist()
test_flora_acceleration.py CHANGED
@@ -105,32 +105,61 @@ def test_tiny_hybrid_gdn_model_keeps_requests_independent_in_left_padded_batch()
105
  assert generate_many(model, processor, inputs, 4, batch_size=4) == single
106
 
107
 
108
- def test_tiny_multimodal_model_keeps_video_features_with_their_own_prompt():
 
109
  from transformers import Qwen3_5Config, Qwen3_5ForConditionalGeneration
110
 
111
  torch.manual_seed(42)
112
  config = Qwen3_5Config(
113
- text_config={"vocab_size": 64, "hidden_size": 32, "intermediate_size": 64,
114
- "num_hidden_layers": 4, "num_attention_heads": 4, "num_key_value_heads": 2,
115
- "head_dim": 8, "linear_num_key_heads": 2, "linear_num_value_heads": 2,
116
- "linear_key_head_dim": 8, "linear_value_head_dim": 8,
117
- "layer_types": ["linear_attention"]*3+["full_attention"],
118
- "pad_token_id":0, "eos_token_id":2,
119
- "rope_parameters":{"rope_type":"default", "rope_theta":10000, "mrope_section":[1,1,2]}},
120
- vision_config={"depth":1, "hidden_size":16, "intermediate_size":32, "num_heads":2,
121
- "in_channels":3, "patch_size":2, "temporal_patch_size":2,
122
- "spatial_merge_size":1, "out_hidden_size":32, "num_position_embeddings":64},
123
- video_token_id=61, image_token_id=59, vision_start_token_id=60, vision_end_token_id=62,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
124
  )
125
  model = Qwen3_5ForConditionalGeneration(config).eval()
126
  model.generation_config.forced_eos_token_id = 2
127
  model.generation_config.min_new_tokens = 2
128
  inputs = []
129
- for prefix in ([5,6], [7,8,9]):
130
- ids = [*prefix,60,61,61,61,61,62,10]
131
- inputs.append({"input_ids":torch.tensor([ids]), "attention_mask":torch.ones(1,len(ids),dtype=torch.long),
132
- "mm_token_type_ids":torch.tensor([[0]*(len(prefix)+1)+[2]*4+[0,0]]),
133
- "pixel_values_videos":torch.randn(4,24), "video_grid_thw":torch.tensor([[1,2,2]])})
 
 
 
 
 
 
134
  processor = SimpleNamespace(tokenizer=Tokenizer())
135
- single = [generate_many(model,processor,[item],4,batch_size=1)[0] for item in inputs]
136
- assert generate_many(model,processor,inputs,4,batch_size=4) == single
 
105
  assert generate_many(model, processor, inputs, 4, batch_size=4) == single
106
 
107
 
108
+ @pytest.mark.parametrize("batch_size", [2, 8])
109
+ def test_tiny_multimodal_model_keeps_video_features_with_their_own_prompt(batch_size):
110
  from transformers import Qwen3_5Config, Qwen3_5ForConditionalGeneration
111
 
112
  torch.manual_seed(42)
113
  config = Qwen3_5Config(
114
+ text_config={
115
+ "vocab_size": 64,
116
+ "hidden_size": 32,
117
+ "intermediate_size": 64,
118
+ "num_hidden_layers": 4,
119
+ "num_attention_heads": 4,
120
+ "num_key_value_heads": 2,
121
+ "head_dim": 8,
122
+ "linear_num_key_heads": 2,
123
+ "linear_num_value_heads": 2,
124
+ "linear_key_head_dim": 8,
125
+ "linear_value_head_dim": 8,
126
+ "layer_types": ["linear_attention"] * 3 + ["full_attention"],
127
+ "pad_token_id": 0,
128
+ "eos_token_id": 2,
129
+ "rope_parameters": {"rope_type": "default", "rope_theta": 10000, "mrope_section": [1, 1, 2]},
130
+ },
131
+ vision_config={
132
+ "depth": 1,
133
+ "hidden_size": 16,
134
+ "intermediate_size": 32,
135
+ "num_heads": 2,
136
+ "in_channels": 3,
137
+ "patch_size": 2,
138
+ "temporal_patch_size": 2,
139
+ "spatial_merge_size": 1,
140
+ "out_hidden_size": 32,
141
+ "num_position_embeddings": 64,
142
+ },
143
+ video_token_id=61,
144
+ image_token_id=59,
145
+ vision_start_token_id=60,
146
+ vision_end_token_id=62,
147
  )
148
  model = Qwen3_5ForConditionalGeneration(config).eval()
149
  model.generation_config.forced_eos_token_id = 2
150
  model.generation_config.min_new_tokens = 2
151
  inputs = []
152
+ for prefix in ([5, 6], [7, 8, 9]) * (batch_size // 2):
153
+ ids = [*prefix, 60, 61, 61, 61, 61, 62, 10]
154
+ inputs.append(
155
+ {
156
+ "input_ids": torch.tensor([ids]),
157
+ "attention_mask": torch.ones(1, len(ids), dtype=torch.long),
158
+ "mm_token_type_ids": torch.tensor([[0] * (len(prefix) + 1) + [2] * 4 + [0, 0]]),
159
+ "pixel_values_videos": torch.randn(4, 24),
160
+ "video_grid_thw": torch.tensor([[1, 2, 2]]),
161
+ }
162
+ )
163
  processor = SimpleNamespace(tokenizer=Tokenizer())
164
+ single = [generate_many(model, processor, [item], 4, batch_size=1)[0] for item in inputs]
165
+ assert generate_many(model, processor, inputs, 4, batch_size=batch_size) == single
test_flora_native.py CHANGED
@@ -159,17 +159,19 @@ def test_resident_mode_rejects_smaller_gpu_before_downloading_weights(monkeypatc
159
 
160
 
161
  def test_acceleration_prefetch_does_not_block_tempseg_and_keeps_native_load(monkeypatch):
162
- import huggingface_hub
163
  from threading import Event
164
 
165
  import flora_acceleration
 
166
 
167
- monkeypatch.setattr(huggingface_hub, "snapshot_download",
168
- lambda *a, **k: os.environ["FLORA_NATIVE_TEST_ROOT"])
 
169
  monkeypatch.setattr(flora_acceleration, "require_fast_kernels", lambda: {"test": "native"})
170
  # Import the pinned modules once without a model/weight download.
171
  reference = FloraCaptioner()
172
  import batch_utils.models as native
 
173
  entered, release = Event(), Event()
174
 
175
  def snapshot(kind):
@@ -181,6 +183,7 @@ def test_acceleration_prefetch_does_not_block_tempseg_and_keeps_native_load(monk
181
  engine = FloraCaptioner(execution="accelerated")
182
  monkeypatch.setattr(engine, "check_resident_capacity", lambda: None)
183
  loaded = []
 
184
  class Session:
185
  def load(self, kind):
186
  loaded.append(kind)
 
159
 
160
 
161
  def test_acceleration_prefetch_does_not_block_tempseg_and_keeps_native_load(monkeypatch):
 
162
  from threading import Event
163
 
164
  import flora_acceleration
165
+ import huggingface_hub
166
 
167
+ monkeypatch.setattr(
168
+ huggingface_hub, "snapshot_download", lambda *a, **k: os.environ["FLORA_NATIVE_TEST_ROOT"]
169
+ )
170
  monkeypatch.setattr(flora_acceleration, "require_fast_kernels", lambda: {"test": "native"})
171
  # Import the pinned modules once without a model/weight download.
172
  reference = FloraCaptioner()
173
  import batch_utils.models as native
174
+
175
  entered, release = Event(), Event()
176
 
177
  def snapshot(kind):
 
183
  engine = FloraCaptioner(execution="accelerated")
184
  monkeypatch.setattr(engine, "check_resident_capacity", lambda: None)
185
  loaded = []
186
+
187
  class Session:
188
  def load(self, kind):
189
  loaded.append(kind)
test_flora_runtime.py CHANGED
@@ -57,6 +57,69 @@ def test_flora_cannot_be_selected_for_vqa_only():
57
  studio.estimate(selection(), ["Visual questions"], 0.1, 1, 0.083333, captioner="flora")
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  def test_runtime_resume_restores_annotations_and_videos_without_provider_calls(
61
  tmp_path, episode, monkeypatch
62
  ):
 
57
  studio.estimate(selection(), ["Visual questions"], 0.1, 1, 0.083333, captioner="flora")
58
 
59
 
60
+ def test_prebuilt_flora_image_requires_complete_measured_acceptance(tmp_path):
61
+ from flora_image import IDENTITY_FILES, validated_worker_image
62
+
63
+ path = tmp_path / "release.json"
64
+ assert validated_worker_image("flora", ["Subtasks"], path) is None
65
+ profile = {
66
+ "repo": "org/worker",
67
+ "revision": "a" * 40,
68
+ "captioner_profile": FLORA,
69
+ "expected_code": dict.fromkeys(IDENTITY_FILES, "b" * 64),
70
+ "acceptance": {"episodes": 25, "independent": True, "fused_kernels": True, "end_to_end_seconds": 899},
71
+ }
72
+ path.write_text(json.dumps(profile))
73
+ assert validated_worker_image("flora", ["Subtasks"], path) == profile
74
+ assert validated_worker_image("flora", ["Subtasks", "Memory"], path) is None
75
+ for key, value in [
76
+ ("end_to_end_seconds", 900),
77
+ ("episodes", 24),
78
+ ("independent", False),
79
+ ("fused_kernels", False),
80
+ ]:
81
+ altered = json.loads(json.dumps(profile))
82
+ altered["acceptance"][key] = value
83
+ path.write_text(json.dumps(altered))
84
+ with pytest.raises(ValueError, match="acceptance"):
85
+ validated_worker_image("flora", ["Subtasks"], path)
86
+
87
+
88
+ def test_flora_launch_uses_only_validated_image_without_reinstalling(monkeypatch):
89
+ from flora_image import IDENTITY_FILES
90
+
91
+ configured = plan()
92
+ configured.update(captioner="flora", captioner_profile=dict(FLORA))
93
+ configured["costs"] = studio.estimate(selection(), ["Subtasks"], 0.1, 1, 0.083333, captioner="flora")
94
+ api = api_mock()
95
+ api.list_jobs_hardware.return_value.append(
96
+ SimpleNamespace(name="h200", unit_label="minute", unit_cost_usd=0.083333)
97
+ )
98
+ image = {
99
+ "repo": "org/worker",
100
+ "revision": api.space_info.return_value.sha,
101
+ "expected_code": dict.fromkeys(IDENTITY_FILES, "b" * 64),
102
+ }
103
+ monkeypatch.setattr(studio, "validated_worker_image", lambda *a: image)
104
+ studio.launch(
105
+ configured,
106
+ "alice/fast",
107
+ 8,
108
+ True,
109
+ "secret",
110
+ space="finerobotics/annotation-studio",
111
+ api_factory=lambda **_: api,
112
+ )
113
+ arguments = api.run_job.call_args.kwargs
114
+ assert arguments["image"] == "hf.co/spaces/org/worker"
115
+ assert arguments["command"] == ["python", "/workspace/app/flora_speed_entry.py"]
116
+ assert arguments["env"]["FLORA_EXECUTION"] == "accelerated"
117
+ assert "secret" not in json.dumps(arguments["env"])
118
+ assert (
119
+ json.loads(arguments["env"]["ANNOTATION_MANIFEST"])["worker_image"]["revision"] == image["revision"]
120
+ )
121
+
122
+
123
  def test_runtime_resume_restores_annotations_and_videos_without_provider_calls(
124
  tmp_path, episode, monkeypatch
125
  ):
test_hand_actions.py ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Native hand flow contracts on the real episode engine, with no paid compute."""
2
+
3
+ import json
4
+ import sys
5
+ from pathlib import Path
6
+ from unittest.mock import Mock
7
+
8
+ import av
9
+ import numpy as np
10
+ import pyarrow as pa
11
+ import pyarrow.parquet as pq
12
+ import pytest
13
+
14
+ sys.path.insert(0, str(Path(__file__).parent))
15
+ import studio
16
+ import worker
17
+ from hand_actions import HANDS, catalog
18
+ from studio_runtime import run_episode
19
+
20
+ from lerobot.annotations.steerable_pipeline.reader import EpisodeRecord
21
+
22
+
23
+ def test_unconfigured_runtime_cannot_claim_ready(monkeypatch):
24
+ monkeypatch.delenv("HAND_JOB_IMAGE", raising=False)
25
+ result = catalog()
26
+ assert result["available"] is False
27
+ assert "pinned HF Jobs" in result["reason"]
28
+ assert "config_template" not in result
29
+
30
+
31
+ def test_hand_only_has_no_qwen_cost_or_provider():
32
+ selection = {"episodes": [{"length": 900}], "seconds": 30, "info": {"fps": 30}}
33
+ costs = studio.estimate(selection, [HANDS], 0.1, 1, 0.08)
34
+ assert costs["inference"] == costs["calls"] == 0
35
+ assert studio.needs_provider([HANDS]) is False
36
+ assert studio.needs_provider([HANDS, "Subtasks"]) is True
37
+ assert studio.needs_provider([HANDS, "Subtasks"], captioner="flora") is False
38
+ assert worker.requested_styles([HANDS]) == set()
39
+
40
+
41
+ @pytest.mark.parametrize("observed", [True, False])
42
+ def test_hand_stage_preserves_frames_and_resumes_without_inference(tmp_path, monkeypatch, observed):
43
+ camera = "observation.images.ego"
44
+ video = tmp_path / f"videos/{camera}/chunk-000/file-000.mp4"
45
+ video.parent.mkdir(parents=True)
46
+ with av.open(str(video), "w") as container:
47
+ stream = container.add_stream("libx264", rate=10, options={"crf": "0"})
48
+ stream.width = stream.height = 32
49
+ stream.pix_fmt = "yuv420p"
50
+ for i in range(40):
51
+ frame = av.VideoFrame.from_ndarray(np.full((32, 32, 3), i, dtype=np.uint8), format="rgb24")
52
+ for packet in stream.encode(frame):
53
+ container.mux(packet)
54
+ for packet in stream.encode():
55
+ container.mux(packet)
56
+ source = pa.table(
57
+ {
58
+ "episode_index": [0] * 40,
59
+ "frame_index": list(range(40)),
60
+ "timestamp": pa.array(np.arange(40) / 10, type=pa.float32()),
61
+ "action": [[float(i), 0.0] for i in range(40)],
62
+ }
63
+ )
64
+ path = tmp_path / "source.parquet"
65
+ pq.write_table(source, path)
66
+ record = EpisodeRecord(0, "human task", tuple(np.arange(40) / 10), tuple(range(40)), path, 0, 40)
67
+ row = {
68
+ "length": 40,
69
+ f"videos/{camera}/chunk_index": 0,
70
+ f"videos/{camera}/file_index": 0,
71
+ f"videos/{camera}/from_timestamp": 0.0,
72
+ }
73
+ info = {"fps": 10, "video_path": "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4"}
74
+ calls = []
75
+
76
+ def native(video, calibration, directory):
77
+ calls.append(video)
78
+ with av.open(str(video)) as clip:
79
+ assert sum(1 for f in clip.decode(video=0)) == 40
80
+ directory.mkdir(parents=True)
81
+ segments = []
82
+ if observed:
83
+ segment = directory / "segment.npz"
84
+ np.savez(
85
+ segment,
86
+ source_frame_index=np.arange(5, 35),
87
+ timestamp_s=np.arange(5, 35) / 10,
88
+ action=np.ones((30, 102)),
89
+ confidence=np.ones((30, 2)),
90
+ )
91
+ segments.append(str(segment))
92
+ return {
93
+ "segments": segments,
94
+ "accepted_frames": 30 if observed else 0,
95
+ "rectified_frames": 40,
96
+ "rectified_fps": 10,
97
+ }
98
+
99
+ monkeypatch.setattr(worker, "HAND_ANNOTATOR", type("Native", (), {"annotate": staticmethod(native)})())
100
+ provider = Mock()
101
+ provider.generate_json.side_effect = AssertionError("Hand-only must never call a VLM")
102
+ manifest = {
103
+ "dataset": "test/source",
104
+ "dataset_revision": "a" * 40,
105
+ "features": [HANDS],
106
+ "camera": camera,
107
+ "vqa_hz": 0.1,
108
+ "hand_actions": {"calibration": {"fx": 25}},
109
+ }
110
+ staging = tmp_path / "staging"
111
+ result = run_episode(record, row, info, tmp_path, manifest, provider, staging)
112
+ assert set(result["runtime"]) == {"language", "hands"}
113
+ assert result["hand_actions"]["valid_frames"] == (30 if observed else 0)
114
+ target = tmp_path / "hand_actions/episode_000000.parquet"
115
+ annotated = pq.read_table(target)
116
+ assert len(annotated) == len(source) == 40
117
+ assert annotated["annotation.original_action"].equals(source["action"])
118
+ assert len(calls) == 1
119
+ resumed = run_episode(record, row, info, tmp_path, manifest, provider, staging)
120
+ assert resumed["hand_actions"] == result["hand_actions"] and len(calls) == 1
121
+ provider.generate_json.assert_not_called()
122
+ assert not list((staging.parent / "runtime").rglob("MANO*"))
123
+
124
+
125
+ def test_launch_uses_only_hf_gpu_image_and_private_dataset(monkeypatch):
126
+ from test_studio import api_mock, plan
127
+
128
+ runtime = {"image": "example/research@sha256:" + "c" * 64, "backend": {}, "recipe": "pinned"}
129
+ monkeypatch.setattr(studio, "runtime_configuration", lambda: runtime)
130
+ api = api_mock()
131
+ api.list_jobs_hardware.return_value = [
132
+ type("Hardware", (), {"name": "h200", "unit_label": "minute", "unit_cost_usd": 0.08})()
133
+ ]
134
+ api.run_job.return_value = type("Job", (), {"id": "only-simulated"})()
135
+ p = plan()
136
+ p.update(features=[HANDS], hand_actions={"compute": "h200", "runtime": runtime})
137
+ p["costs"] = studio.estimate(p["selection"], [HANDS], 0.1, 1, 0.08)
138
+ studio.launch(
139
+ p,
140
+ "alice/hands",
141
+ 10,
142
+ True,
143
+ "secret-token",
144
+ space="finerobotics/annotation-studio",
145
+ api_factory=lambda **kw: api,
146
+ )
147
+ launch = api.run_job.call_args.kwargs
148
+ assert launch["flavor"] == "h200" and launch["image"] == runtime["image"]
149
+ assert launch["secrets"] == {"HF_TOKEN": "secret-token"}
150
+ manifest = json.loads(launch["env"]["ANNOTATION_MANIFEST"])
151
+ assert manifest["provider_required"] is False and manifest["subtask_models"] == []
152
+ assert manifest["episode_parallelism"] == 1 and manifest["visibility"] == "private"
153
+ api.model_info.assert_not_called()
154
+ with pytest.raises(ValueError, match="private"):
155
+ studio.launch(
156
+ p, "alice/public", 10, True, "secret-token", visibility="public", api_factory=lambda **kw: api
157
+ )
158
+ assert api.run_job.call_count == 1
test_studio.py CHANGED
@@ -23,7 +23,13 @@ from lerobot.annotations.steerable_pipeline.vlm_client import StubVlmClient
23
 
24
 
25
  @pytest.mark.parametrize(
26
- "features", [list(studio.FEATURES), ["Interjections & speech"], ["Memory"], ["Visual questions"]]
 
 
 
 
 
 
27
  )
28
  @pytest.mark.parametrize("custom", [False, True])
29
  def test_real_video_pipeline_and_checkbox_filtering(tmp_path, features, custom):
 
23
 
24
 
25
  @pytest.mark.parametrize(
26
+ "features",
27
+ [
28
+ [f for f in studio.FEATURES if f != studio.HANDS],
29
+ ["Interjections & speech"],
30
+ ["Memory"],
31
+ ["Visual questions"],
32
+ ],
33
  )
34
  @pytest.mark.parametrize("custom", [False, True])
35
  def test_real_video_pipeline_and_checkbox_filtering(tmp_path, features, custom):
test_subset.py CHANGED
@@ -120,7 +120,8 @@ def shared_source(tmp_path):
120
  return root, cameras, path
121
 
122
 
123
- def test_successful_episodes_only_all_cameras_and_real_lerobot_load(shared_source, tmp_path):
 
124
  root, cameras, data_path = shared_source
125
  records = [r for r in iter_episodes(root) if r.episode_index != 1]
126
  staging = tmp_path / "staging"
@@ -141,7 +142,7 @@ def test_successful_episodes_only_all_cameras_and_real_lerobot_load(shared_sourc
141
  PreservingWriter([0, 2], {"subtask"}).write_all(records, staging, root)
142
  Executor._ensure_annotation_metadata_in_info(root)
143
  output = tmp_path / "subset"
144
- builder = EpisodeSubset(root, output, [0, 1, 2])
145
  table = pq.read_table(data_path)
146
  builder.add(0, table)
147
  # Loading a checkpoint works before all completed episodes have been copied.
@@ -422,3 +423,42 @@ def test_dense_frame_sampling_matches_source_pixels_with_one_seek(tmp_path, monk
422
  assert len(seeks) == 1 # Previously 40 GOP seeks for the same pixels.
423
  source.frames_at(SimpleNamespace(), [i / 30 for i in reversed(requested)])
424
  assert len(seeks) == 1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
120
  return root, cameras, path
121
 
122
 
123
+ @pytest.mark.parametrize("camera_workers", [1, 3])
124
+ def test_successful_episodes_only_all_cameras_and_real_lerobot_load(shared_source, tmp_path, camera_workers):
125
  root, cameras, data_path = shared_source
126
  records = [r for r in iter_episodes(root) if r.episode_index != 1]
127
  staging = tmp_path / "staging"
 
142
  PreservingWriter([0, 2], {"subtask"}).write_all(records, staging, root)
143
  Executor._ensure_annotation_metadata_in_info(root)
144
  output = tmp_path / "subset"
145
+ builder = EpisodeSubset(root, output, [0, 1, 2], camera_workers=camera_workers)
146
  table = pq.read_table(data_path)
147
  builder.add(0, table)
148
  # Loading a checkpoint works before all completed episodes have been copied.
 
423
  assert len(seeks) == 1 # Previously 40 GOP seeks for the same pixels.
424
  source.frames_at(SimpleNamespace(), [i / 30 for i in reversed(requested)])
425
  assert len(seeks) == 1
426
+
427
+
428
+ def test_hand_actions_load_in_same_dataset_and_keep_unobserved_episode(shared_source, tmp_path):
429
+ from lerobot.annotations.hands.writer import FEATURES as HAND_FEATURES, attach_actions
430
+
431
+ root, cameras, data_path = shared_source
432
+ builder = EpisodeSubset(root, tmp_path / "annotated", [0, 2])
433
+ source = pq.read_table(data_path)
434
+ for episode in (0, 2):
435
+ table = source.filter(pa.compute.equal(source["episode_index"], episode))
436
+ # Entirely unobserved episodes remain usable dataset episodes, with explicit masks.
437
+ table, summary = attach_actions(
438
+ table,
439
+ {"rectified_frames": 10, "rectified_fps": 10, "accepted_frames": 0, "segments": []},
440
+ directory=tmp_path,
441
+ fps=10,
442
+ )
443
+ builder.add(episode, table, hand_features=HAND_FEATURES)
444
+ assert summary["unmeasured_frames"] == 10
445
+ result = LeRobotDataset("test/annotated", root=builder.root, video_backend="pyav", token=False)
446
+ assert len(result) == 20 and result.num_episodes == 2
447
+ assert tuple(result[0]["action"].shape) == (102,)
448
+ assert tuple(result[0]["annotation.original_action"].shape) == (2,)
449
+ assert not result[0]["annotation.hand_action_valid"].any()
450
+ table = pq.read_table(builder.root / "data")
451
+ assert (
452
+ table["annotation.original_action"].to_pylist()
453
+ == source.filter(pa.compute.is_in(source["episode_index"], value_set=pa.array([0, 2])))[
454
+ "action"
455
+ ].to_pylist()
456
+ )
457
+ stats = json.loads((builder.root / "meta/stats.json").read_text())
458
+ assert len(stats["action"]["mean"]) == 102
459
+ assert len(stats["annotation.original_action"]["mean"]) == 2
460
+ assert builder.mapping == [
461
+ {"episode_index": 0, "source_episode_index": 0},
462
+ {"episode_index": 1, "source_episode_index": 2},
463
+ ]
464
+ assert len(list((builder.root / "videos").rglob("*.mp4"))) == 4
worker.py CHANGED
@@ -17,6 +17,7 @@ from pathlib import Path
17
 
18
  import av
19
  import pyarrow.parquet as pq
 
20
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
21
  from human_video import FalMedia, generate_human_videos
22
  from prompt_config import validate_prompts
@@ -45,6 +46,7 @@ STUDIO_ACTIVE = ContextVar("studio_active", default=False)
45
  ORIGINAL_LOAD = plan_module.load_prompt
46
  ACTION_PROMPT = Path(__file__).with_name("action-phases.txt").read_text()
47
  FLORA_CAPTIONER = None
 
48
 
49
 
50
  class IndependentFloraClient:
@@ -244,7 +246,7 @@ class PreservingWriter(LanguageColumnsWriter):
244
 
245
  def requested_styles(features):
246
  # Human demo videos are media files, not language rows.
247
- styles = {FEATURES[f] for f in features if f != HUMAN_VIDEOS}
248
  if "Interjections & speech" in features:
249
  styles.add(None)
250
  return styles
@@ -289,7 +291,7 @@ def _annotate_episode(record, row, info, root, manifest, provider, staging_dir,
289
  cfg.derive_task_from_video = "off"
290
  plan.caption_frames = SourceFrames(row, info, root, manifest["camera"], max_size=None)
291
  plan.caption_fps = info["fps"]
292
- if set(features) - {"Visual questions"}:
293
  plan.run_episode(record, staging)
294
  if "Interjections & speech" in features:
295
  module = InterjectionsAndSpeechModule(
@@ -443,7 +445,9 @@ def create_output_repo(api, manifest):
443
 
444
 
445
  def run(manifest, work=Path("/workspace/run")):
446
- global FLORA_CAPTIONER
 
 
447
  deadline = float(os.environ.get("ANNOTATION_DEADLINE_EPOCH", "0")) or None
448
  if manifest.get("captioner") == "flora":
449
  from flora_captioner import FLORA, FloraCaptioner
@@ -486,7 +490,9 @@ def run(manifest, work=Path("/workspace/run")):
486
  local_dir=root,
487
  allow_patterns=["meta/**", "README.md", "LICENSE*", "COPYING*"],
488
  )
489
- subset = EpisodeSubset(root, work / "subset", ids)
 
 
490
  attribution = []
491
  for file in root.iterdir():
492
  if file.is_file() and (file.name == "README.md" or file.name.startswith(("LICENSE", "COPYING"))):
@@ -585,6 +591,20 @@ def run(manifest, work=Path("/workspace/run")):
585
  )
586
  shards[path].append(row)
587
  try:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  for data_path, rows in shards.items():
589
  files = {data_path} | {video_path(row, info, manifest["camera"]) for row in rows}
590
  sizes = api.get_paths_info(
@@ -654,7 +674,29 @@ def run(manifest, work=Path("/workspace/run")):
654
  local_dir=root,
655
  )
656
  shard_files.update(extra_files)
657
- new_id, written = subset.add(record.episode_index, annotated_table)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
658
  extra_operations = []
659
  with commit_lock:
660
  for entry in human_index:
@@ -787,6 +829,10 @@ def run(manifest, work=Path("/workspace/run")):
787
  progress.update(status="partial", error_type=type(exc).__name__, worker_finished_at=time.time())
788
  checkpoint()
789
  raise RuntimeError("Annotation stopped; check the saved run report for completed episodes.") from None
 
 
 
 
790
  print(f"Result: {progress['status']} — https://huggingface.co/datasets/{output}", flush=True)
791
 
792
 
 
17
 
18
  import av
19
  import pyarrow.parquet as pq
20
+ from hand_actions import HANDS
21
  from huggingface_hub import CommitOperationAdd, HfApi, hf_hub_download, snapshot_download
22
  from human_video import FalMedia, generate_human_videos
23
  from prompt_config import validate_prompts
 
46
  ORIGINAL_LOAD = plan_module.load_prompt
47
  ACTION_PROMPT = Path(__file__).with_name("action-phases.txt").read_text()
48
  FLORA_CAPTIONER = None
49
+ HAND_ANNOTATOR = None
50
 
51
 
52
  class IndependentFloraClient:
 
246
 
247
  def requested_styles(features):
248
  # Human demo videos are media files, not language rows.
249
+ styles = {FEATURES[f] for f in features if f not in {HUMAN_VIDEOS, HANDS}}
250
  if "Interjections & speech" in features:
251
  styles.add(None)
252
  return styles
 
291
  cfg.derive_task_from_video = "off"
292
  plan.caption_frames = SourceFrames(row, info, root, manifest["camera"], max_size=None)
293
  plan.caption_fps = info["fps"]
294
+ if set(features) - {"Visual questions", HANDS}:
295
  plan.run_episode(record, staging)
296
  if "Interjections & speech" in features:
297
  module = InterjectionsAndSpeechModule(
 
445
 
446
 
447
  def run(manifest, work=Path("/workspace/run")):
448
+ global FLORA_CAPTIONER, HAND_ANNOTATOR
449
+ if (HANDS in manifest["features"]) != bool(manifest.get("hand_actions")):
450
+ raise ValueError("Hand action selection and pinned runtime configuration differ")
451
  deadline = float(os.environ.get("ANNOTATION_DEADLINE_EPOCH", "0")) or None
452
  if manifest.get("captioner") == "flora":
453
  from flora_captioner import FLORA, FloraCaptioner
 
490
  local_dir=root,
491
  allow_patterns=["meta/**", "README.md", "LICENSE*", "COPYING*"],
492
  )
493
+ subset = EpisodeSubset(
494
+ root, work / "subset", ids, camera_workers=3 if manifest.get("captioner") == "flora" else 1
495
+ )
496
  attribution = []
497
  for file in root.iterdir():
498
  if file.is_file() and (file.name == "README.md" or file.name.startswith(("LICENSE", "COPYING"))):
 
591
  )
592
  shards[path].append(row)
593
  try:
594
+ if manifest.get("hand_actions"):
595
+ from lerobot.annotations.hands.assets import download_mano
596
+ from lerobot.annotations.hands.config import HandBackendConfig
597
+ from lerobot.annotations.hands.processing import HandAnnotator
598
+
599
+ hands = manifest["hand_actions"]
600
+ receipt = download_mano(hands["mano"], token, work / "private-mano")
601
+ hands["asset_checksums"] = {
602
+ side: {key: value for key, value in entry.items() if key != "path"}
603
+ for side, entry in receipt.items()
604
+ }
605
+ HAND_ANNOTATOR = HandAnnotator(
606
+ HandBackendConfig(**hands["runtime"]["backend"]), receipt, work / "hands-native"
607
+ )
608
  for data_path, rows in shards.items():
609
  files = {data_path} | {video_path(row, info, manifest["camera"]) for row in rows}
610
  sizes = api.get_paths_info(
 
674
  local_dir=root,
675
  )
676
  shard_files.update(extra_files)
677
+ hand_features = None
678
+ if manifest.get("hand_actions"):
679
+ from lerobot.annotations.hands.writer import FEATURES as HAND_FEATURES
680
+
681
+ hand_features = HAND_FEATURES
682
+
683
+ hand_path = (
684
+ staging.parent / "hand_actions" / f"episode_{record.episode_index:06d}.parquet"
685
+ )
686
+ hand_table = pq.read_table(hand_path)
687
+ if not hand_table["frame_index"].equals(annotated_table["frame_index"]):
688
+ raise ValueError("Hand actions do not match the exported episode")
689
+ for name in [*HAND_FEATURES, "annotation.original_action"]:
690
+ if name in hand_table.column_names:
691
+ index = annotated_table.schema.get_field_index(name)
692
+ annotated_table = (
693
+ annotated_table.set_column(index, name, hand_table[name])
694
+ if index >= 0
695
+ else annotated_table.append_column(name, hand_table[name])
696
+ )
697
+ new_id, written = subset.add(
698
+ record.episode_index, annotated_table, hand_features=hand_features
699
+ )
700
  extra_operations = []
701
  with commit_lock:
702
  for entry in human_index:
 
829
  progress.update(status="partial", error_type=type(exc).__name__, worker_finished_at=time.time())
830
  checkpoint()
831
  raise RuntimeError("Annotation stopped; check the saved run report for completed episodes.") from None
832
+ finally:
833
+ if HAND_ANNOTATOR is not None:
834
+ HAND_ANNOTATOR.close()
835
+ HAND_ANNOTATOR = None
836
  print(f"Result: {progress['status']} — https://huggingface.co/datasets/{output}", flush=True)
837
 
838