Integrate Hand actions into the annotation wizard with HF Jobs and private MANO assets
Browse files- Dockerfile +2 -1
- Dockerfile.flora +14 -2
- build_causal_conv1d.py +54 -0
- deploy.py +2 -2
- flora_acceleration.py +2 -0
- flora_image.py +52 -0
- frontend/src/HandActionSettings.tsx +169 -0
- frontend/src/HandSetupView.tsx +0 -353
- frontend/src/hands.css +0 -87
- frontend/src/main.tsx +111 -28
- frontend/src/wizard.css +32 -0
- hand-pipeline-catalog.json +12 -80
- hand_actions.py +67 -0
- pipeline-source.tar.gz +2 -2
- pipeline-version.json +2 -2
- requirements.txt +1 -0
- server.py +13 -2
- studio.py +57 -12
- studio_runtime.py +47 -2
- subset.py +48 -7
- test_flora_acceleration.py +48 -19
- test_flora_native.py +6 -3
- test_flora_runtime.py +63 -0
- test_hand_actions.py +158 -0
- test_studio.py +7 -1
- test_subset.py +42 -2
- worker.py +51 -5
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=
|
|
|
|
| 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="
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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"
|
|
|
|
| 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", "
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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 &&
|
|
|
|
|
|
|
|
|
|
| 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 |
-
:
|
| 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
|
| 867 |
"Review your run",
|
| 868 |
][step]
|
| 869 |
}
|
|
@@ -1207,7 +1234,50 @@ function App() {
|
|
| 1207 |
)}
|
| 1208 |
</section>
|
| 1209 |
)}
|
| 1210 |
-
{step ===
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
{
|
| 1421 |
-
? "
|
| 1422 |
-
:
|
|
|
|
|
|
|
| 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 |
-
{
|
| 1587 |
-
|
| 1588 |
-
|
|
|
|
| 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 |
-
{
|
| 1985 |
-
? "
|
| 1986 |
-
:
|
|
|
|
|
|
|
| 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": "
|
| 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 |
-
"
|
| 38 |
-
"
|
| 39 |
-
"output_root": "hand-annotations",
|
| 40 |
-
"repo_id": "local/human-pov-hands",
|
| 41 |
-
"compute": "rtx-5090",
|
| 42 |
"planning_fps": null,
|
| 43 |
-
"
|
| 44 |
-
"
|
| 45 |
-
"
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 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": "
|
| 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:
|
| 3 |
-
size
|
|
|
|
| 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": "
|
| 3 |
-
"sha256": "
|
| 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=
|
|
|
|
| 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
|
| 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 |
-
"""
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
| 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": [
|
|
|
|
|
|
|
| 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=
|
| 611 |
-
command=
|
| 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=
|
| 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("
|
| 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
|
| 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(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
| 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 |
-
|
|
|
|
| 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 |
-
|
|
|
|
| 109 |
from transformers import Qwen3_5Config, Qwen3_5ForConditionalGeneration
|
| 110 |
|
| 111 |
torch.manual_seed(42)
|
| 112 |
config = Qwen3_5Config(
|
| 113 |
-
text_config={
|
| 114 |
-
|
| 115 |
-
|
| 116 |
-
|
| 117 |
-
|
| 118 |
-
|
| 119 |
-
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 132 |
-
|
| 133 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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=
|
|
|
|
| 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(
|
| 168 |
-
|
|
|
|
| 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",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
| 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
|
| 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(
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
|