RyanL22 commited on
Commit
1454d47
·
0 Parent(s):

RoboTryOn demo

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .gitattributes +46 -0
  2. .gitignore +3 -0
  3. README.md +16 -0
  4. app.py +135 -0
  5. configs/image/infer.yaml +32 -0
  6. configs/video/infer.yaml +35 -0
  7. core.py +194 -0
  8. packages.txt +1 -0
  9. requirements.txt +14 -0
  10. robotryon/__init__.py +0 -0
  11. robotryon/captions.py +146 -0
  12. robotryon/common/__init__.py +0 -0
  13. robotryon/common/config.py +115 -0
  14. robotryon/embodiment/__init__.py +25 -0
  15. robotryon/embodiment/base.py +144 -0
  16. robotryon/embodiment/camera.py +103 -0
  17. robotryon/embodiment/mujoco_embodiment.py +320 -0
  18. robotryon/embodiment/retarget.py +122 -0
  19. robotryon/embodiment/spec.py +201 -0
  20. robotryon/idm/__init__.py +5 -0
  21. robotryon/idm/data.py +319 -0
  22. robotryon/idm/dit.py +143 -0
  23. robotryon/idm/labeling.py +99 -0
  24. robotryon/idm/model.py +327 -0
  25. robotryon/idm/wrist_correction.py +229 -0
  26. robotryon/image/__init__.py +14 -0
  27. robotryon/image/ar_keyframes.py +161 -0
  28. robotryon/image/dataset.py +227 -0
  29. robotryon/image/episodes.py +173 -0
  30. robotryon/image/fill_channels.py +92 -0
  31. robotryon/image/model.py +138 -0
  32. robotryon/image/sampler.py +121 -0
  33. robotryon/image/stereo_ids.py +34 -0
  34. robotryon/image/text.py +56 -0
  35. robotryon/image/training.py +48 -0
  36. robotryon/masks/__init__.py +14 -0
  37. robotryon/masks/augment.py +131 -0
  38. robotryon/masks/hull.py +53 -0
  39. robotryon/masks/reference.py +29 -0
  40. robotryon/skeleton/__init__.py +8 -0
  41. robotryon/skeleton/augment.py +75 -0
  42. robotryon/skeleton/render.py +75 -0
  43. robotryon/skeleton/topology.py +90 -0
  44. robotryon/video/__init__.py +15 -0
  45. robotryon/video/chunks.py +164 -0
  46. robotryon/video/conditioning.py +150 -0
  47. robotryon/video/dataset.py +167 -0
  48. robotryon/video/io.py +52 -0
  49. robotryon/video/pipeline.py +271 -0
  50. robotryon/video/rope4d.py +129 -0
.gitattributes ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ examples/gr1_stir_pot/episode/video_ego_view.mp4 filter=lfs diff=lfs merge=lfs -text
37
+ examples/gr1_stir_pot/input.mp4 filter=lfs diff=lfs merge=lfs -text
38
+ examples/openarm_box_convey/episode/mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
39
+ examples/openarm_box_convey/episode/object_mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
40
+ examples/openarm_box_convey/episode/video_left.mp4 filter=lfs diff=lfs merge=lfs -text
41
+ examples/openarm_box_convey/input.mp4 filter=lfs diff=lfs merge=lfs -text
42
+ examples/openarm_box_convey/reference.png filter=lfs diff=lfs merge=lfs -text
43
+ examples/rby1_bottle_pour/episode/mask_left.mp4 filter=lfs diff=lfs merge=lfs -text
44
+ examples/rby1_bottle_pour/episode/video_left.mp4 filter=lfs diff=lfs merge=lfs -text
45
+ examples/rby1_bottle_pour/input.mp4 filter=lfs diff=lfs merge=lfs -text
46
+ examples/rby1_bottle_pour/reference.png filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ __pycache__/
2
+ prep_assets.py
3
+ assets/
README.md ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: RoboTryOn
3
+ emoji: 🤖
4
+ colorFrom: gray
5
+ colorTo: indigo
6
+ sdk: gradio
7
+ sdk_version: 5.50.0
8
+ python_version: "3.10"
9
+ app_file: app.py
10
+ startup_duration_timeout: 1h
11
+ pinned: false
12
+ short_description: Human videos to dexterous robot videos via virtual try-on
13
+ ---
14
+
15
+ Demo of [RoboTryOn](https://github.com/ryan-seungyong-lee/RoboTryOn) ([paper](https://arxiv.org/abs/2611.05163)).
16
+ `robotryon/`, `scripts/infer_video.py` and `configs/` are copies of the code repository.
app.py ADDED
@@ -0,0 +1,135 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import tempfile
3
+
4
+ import gradio as gr
5
+ import spaces
6
+
7
+ from core import HERE, Demo
8
+
9
+ DEMO = Demo(load_models=os.environ.get("ROBOTRYON_UI_ONLY") != "1")
10
+ CLIPS = DEMO.manifest["clips"]
11
+ EMB_KEY = {e["name"]: e["key"] for e in DEMO.manifest["embodiments"]}
12
+ EMB_NAME = {v: k for k, v in EMB_KEY.items()}
13
+ CLIP_LABEL = {c["key"]: f'{c["name"]} ({c["source"]})' for c in CLIPS}
14
+ CLIP_KEY = {v: k for k, v in CLIP_LABEL.items()}
15
+ CODE = "https://github.com/ryan-seungyong-lee/RoboTryOn"
16
+
17
+
18
+ def _p(rel):
19
+ return str(HERE / rel)
20
+
21
+
22
+ def _sel(clip_label, emb_name):
23
+ return CLIP_KEY[clip_label], EMB_KEY[emb_name]
24
+
25
+
26
+ def stage1_duration(clip_label, emb_name, *_):
27
+ return 20 + 25 * DEMO.episode(*_sel(clip_label, emb_name))["num_keyframes"] # A100: ~34 s per keyframe
28
+
29
+
30
+ def stage2_duration(clip_label, emb_name, *_):
31
+ return 40 + 60 * DEMO.episode(*_sel(clip_label, emb_name))["num_clips"] # A100: ~60 s per clip
32
+
33
+
34
+ @spaces.GPU(duration=stage1_duration)
35
+ def _stage1(clip_label, emb_name, progress=gr.Progress(track_tqdm=True)):
36
+ clip, emb = _sel(clip_label, emb_name)
37
+ kf_dir, _ = DEMO.translate(clip, emb, tempfile.mkdtemp(prefix="keyframes_"))
38
+ return str(kf_dir / "grid.jpg"), (clip, emb, str(kf_dir))
39
+
40
+
41
+ @spaces.GPU(duration=stage2_duration, size="xlarge") # LTX-2.5 needs more than a half H200
42
+ def _stage2(clip_label, emb_name, kf_dir, progress=gr.Progress(track_tqdm=True)):
43
+ clip, emb = _sel(clip_label, emb_name)
44
+ return str(DEMO.synthesize(clip, emb, kf_dir, tempfile.mkdtemp(prefix="video_")))
45
+
46
+
47
+ def stage1(clip_label, emb_name):
48
+ return _stage1(clip_label, emb_name)
49
+
50
+
51
+ def stage2(clip_label, emb_name, state):
52
+ if not state or tuple(state[:2]) != _sel(clip_label, emb_name):
53
+ raise gr.Error("Run stage 1 (keyframe translation) for this video and embodiment first.")
54
+ return _stage2(clip_label, emb_name, state[2])
55
+
56
+
57
+ def pick_clip(evt: gr.SelectData):
58
+ return CLIP_LABEL[CLIPS[evt.index]["key"]]
59
+
60
+
61
+ def show_clip(clip_label, emb_name, state):
62
+ clip = DEMO.clips[CLIP_KEY[clip_label]]
63
+ keep = state and tuple(state[:2]) == _sel(clip_label, emb_name)
64
+ return (_p(clip["video"]), clip["caption"],
65
+ gr.update(selected_index=[c["key"] for c in CLIPS].index(clip["key"])),
66
+ *((gr.update(), gr.update()) if keep else (None, None)))
67
+
68
+
69
+ def show_embodiment(clip_label, emb_name, state):
70
+ keep = state and tuple(state[:2]) == _sel(clip_label, emb_name)
71
+ return (_p(DEMO.embodiments[EMB_KEY[emb_name]]["reference"]),
72
+ *((gr.update(), gr.update()) if keep else (None, None)))
73
+
74
+
75
+ def load_example(clip_label, emb_name):
76
+ clip, emb = _sel(clip_label, emb_name)
77
+ ex = next(e for e in DEMO.manifest["examples"] if e["clip"] == clip and e["embodiment"] == emb)
78
+ return _p(ex["grid"]), _p(ex["video"]), (clip, emb, _p(ex["keyframes"]))
79
+
80
+
81
+ CSS = """
82
+ #clips .thumbnail-item { aspect-ratio: 16 / 9 !important; }
83
+ #clips .thumbnail-item img { object-fit: contain !important; }
84
+ #clips .caption-label { font-size: 12px; }
85
+ """
86
+
87
+ first = CLIPS[0]
88
+ with gr.Blocks(title="RoboTryOn", css=CSS) as demo:
89
+ gr.Markdown(
90
+ "# RoboTryOn\n"
91
+ "**Synthesizing Dexterous Robot Demonstrations from Human Videos via Virtual Try-On** · "
92
+ f"[Paper](https://arxiv.org/abs/2611.05163) · "
93
+ f"[Project page](https://ryan-seungyong-lee.github.io/RoboTryOn) · [Code]({CODE})\n\n"
94
+ "The input videos are preprocessed in advance (hand reconstruction, retargeting to each "
95
+ "robot hand, arm masks) due to the time limits of this Space. "
96
+ f"To run RoboTryOn on your own videos, see the [code]({CODE}).")
97
+ state = gr.State(None)
98
+
99
+ gr.Markdown("### 1. Human video")
100
+ gallery = gr.Gallery([(_p(c["thumb"]), c["name"]) for c in CLIPS], columns=9,
101
+ rows=1, height=120, object_fit="contain", allow_preview=False,
102
+ selected_index=0, show_label=False, elem_id="clips")
103
+ with gr.Row():
104
+ human = gr.Video(_p(first["video"]), label="Human video", interactive=False, height=220,
105
+ scale=1)
106
+ with gr.Column(scale=2):
107
+ clip_dd = gr.Dropdown(list(CLIP_LABEL.values()), value=CLIP_LABEL[first["key"]],
108
+ label="Selected video")
109
+ caption = gr.Textbox(first["caption"], label="Task", interactive=False, lines=3)
110
+
111
+ gr.Markdown("### 2. Embodiment")
112
+ with gr.Row():
113
+ emb_radio = gr.Radio(list(EMB_KEY), value=next(iter(EMB_KEY)), label="Target embodiment")
114
+ reference = gr.Image(_p(DEMO.manifest["embodiments"][0]["reference"]),
115
+ label="Reference", interactive=False, height=180)
116
+
117
+ gr.Markdown("### 3. Generate")
118
+ with gr.Row():
119
+ b1 = gr.Button("Stage 1: Keyframe translation", variant="primary")
120
+ b2 = gr.Button("Stage 2: Video synthesis (uses the stage-1 keyframes)", variant="primary")
121
+ grid = gr.Image(label="Keyframes: human (top) and robot (bottom)", type="filepath")
122
+ video = gr.Video(label="Robot video")
123
+
124
+ gr.Markdown("### Examples (precomputed)")
125
+ gr.Examples([[CLIP_LABEL[e["clip"]], EMB_NAME[e["embodiment"]]] for e in DEMO.manifest["examples"]],
126
+ inputs=[clip_dd, emb_radio], outputs=[grid, video, state], fn=load_example,
127
+ run_on_click=True, cache_examples=False)
128
+
129
+ gallery.select(pick_clip, None, clip_dd)
130
+ clip_dd.change(show_clip, [clip_dd, emb_radio, state], [human, caption, gallery, grid, video])
131
+ emb_radio.change(show_embodiment, [clip_dd, emb_radio, state], [reference, grid, video])
132
+ b1.click(stage1, [clip_dd, emb_radio], [grid, state])
133
+ b2.click(stage2, [clip_dd, emb_radio, state], video)
134
+
135
+ demo.queue().launch()
configs/image/infer.yaml ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Autoregressive keyframe translation (paper Eq. 3.2). See scripts/infer_keyframes.py.
2
+
3
+ model:
4
+ base: black-forest-labs/FLUX.2-klein-base-9B
5
+ checkpoint: null # trained checkpoint dir (FFT: has transformer/; LoRA: has pytorch_lora_weights.safetensors)
6
+
7
+ episodes: [] # preprocessed human episode directories (with keyframes.json)
8
+ reference: {} # {view: robot-only reference image}; a single path is used for every view
9
+ output_dir: runs/keyframes
10
+
11
+ views: auto # auto (joint stereo when the episode has two views) | stereo | mono
12
+ ref_skeleton: false # false: reuse the previous keyframe's latent as reference tokens
13
+ # true: decode it, paint the current skeleton, re-encode
14
+ steps: 30
15
+ guidance_scale: 3.5 # true CFG against the empty prompt
16
+ seed: 2024 # keyframe t uses seed + t
17
+
18
+ image:
19
+ long_edge: 1024
20
+ aspect: [16, 9]
21
+ crop_anchor: bottom
22
+
23
+ masks: # inference dilation = (dilate_min + dilate_max) // 2 + bonus = 58 px
24
+ dilate_min: 3
25
+ dilate_max: 71
26
+ bonus: 21
27
+ obj_erode: 9 # px, erode the object mask before subtracting it from the hole
28
+
29
+ caption: # per-keyframe hand-state prompt (robotryon.captions)
30
+ cuts: # closure (rad) / dorsumness (deg) edges -> open|half-closed|closed fist, palm|edge-on|back
31
+ closure: [0.95, 1.15]
32
+ facing: [49.0, 121.0]
configs/video/infer.yaml ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Video synthesis from translated robot keyframes and the human skeleton flow (paper App. B.4).
2
+ model:
3
+ base: Lightricks/LTX-2.5-Diffusers
4
+ transformer_subfolder: transformer_full
5
+ lora: outputs/video/stage2/checkpoint-001000 # directory with pytorch_lora_weights.safetensors
6
+ attention_backend: native
7
+
8
+ input:
9
+ episode: null # human episode dir: meta.json, keypoints_<view>.npz, keyframes.json
10
+ keyframes_dir: null # image-model output for this episode: keyframe_<view>_<frame:06d>.png + keyframes.json
11
+ views: null # default: all views in meta.json
12
+ stereo: true # generate two views jointly in one sequence; false = one view at a time
13
+
14
+ output:
15
+ dir: outputs/video/infer
16
+
17
+ text:
18
+ template: "{caption}"
19
+ embodiment: null # fills {embodiment} in the template (target robot)
20
+
21
+ fps: 20
22
+ resolution: [1024, 576] # output, per view
23
+ skeleton_size: [512, 288] # as in training: reference downscale 1 at 512x288, 2 at 1024x576
24
+ mask_human: true # true: skeleton flow on black | false: skeleton drawn on the unmasked human video
25
+ frames_range: [49, 145] # clip length limits (8k+1)
26
+ seed: 0 # clip i uses seed + i
27
+
28
+ two_stage:
29
+ mode: same_res # same_res: 8 steps + 3 refine steps at 1024x576
30
+ # upsample: 8 steps at 512x288 -> latent upsampler x2 -> 3 steps at 1024x576
31
+
32
+ skeleton:
33
+ draw:
34
+ thickness: 2
35
+ radius: 3
core.py ADDED
@@ -0,0 +1,194 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The two demo stages, run with the RoboTryOn code as released (``robotryon/``,
2
+ ``scripts/infer_video.py``, ``configs/``).
3
+
4
+ The human clips are preprocessed in advance for every embodiment (hand reconstruction,
5
+ retargeting to the robot hand, arm masks, keyframe selection) and their prompts are encoded in
6
+ advance, so neither the embodiment models nor the text encoders are needed here.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib.util
11
+ import json
12
+ import os
13
+ import subprocess
14
+ from pathlib import Path
15
+
16
+ import cv2
17
+ import numpy as np
18
+ import torch
19
+ from huggingface_hub import snapshot_download
20
+ from PIL import Image, ImageDraw, ImageFont
21
+
22
+ from robotryon.common.config import load_config
23
+ from robotryon.image.ar_keyframes import KeyframeTranslator
24
+ from robotryon.image.episodes import Episode, FrameReader, load_view_frame
25
+ from robotryon.image.model import load_pipeline as load_image_pipeline
26
+
27
+ HERE = Path(__file__).resolve().parent
28
+ ASSETS = HERE / "assets"
29
+ _spec = importlib.util.spec_from_file_location("infer_video", HERE / "scripts" / "infer_video.py")
30
+ iv = importlib.util.module_from_spec(_spec)
31
+ _spec.loader.exec_module(iv)
32
+
33
+ CKPT_REPO = "RyanL22/RoboTryOn"
34
+ ASSETS_REPO = "RyanL22/RoboTryOn-demo-assets" # dataset: preprocessed clips, encoded prompts, examples
35
+ FLUX_REPO = "black-forest-labs/FLUX.2-klein-base-9B"
36
+ LTX_REPO = "Lightricks/LTX-2.5-Diffusers"
37
+ # base-model revisions the demo was tested with (later LTX revisions add components)
38
+ FLUX_REVISION = "32773329fbe7e81a90ef971740e8ba4b0364ecf3"
39
+ LTX_REVISION = "a6de4b5354f078db24d9cf4778c14846788aea3d"
40
+ FLUX_FILES = ["model_index.json", "scheduler/*", "transformer/*", "vae/*"]
41
+ LTX_FILES = ["model_index.json", "scheduler/*", "transformer_full/*", "vae/*", "audio_vae/*",
42
+ "vocoder/*", "connectors/*", "diffusion_decoder/*", "duration_head/*",
43
+ "ltx-2.5-22b-distilled-lora-450-bf16.safetensors"]
44
+ IMAGE_CONFIG = HERE / "configs" / "image" / "infer.yaml"
45
+ VIDEO_CONFIG = HERE / "configs" / "video" / "infer.yaml"
46
+
47
+
48
+ def load_manifest() -> dict:
49
+ if not (ASSETS / "manifest.json").exists():
50
+ snapshot_download(ASSETS_REPO, repo_type="dataset", local_dir=ASSETS)
51
+ return json.loads((ASSETS / "manifest.json").read_text())
52
+
53
+
54
+ def _dir(env: str, repo: str, files: list[str] | None, revision: str | None = None) -> str:
55
+ return os.environ.get(env) or snapshot_download(repo, allow_patterns=files, revision=revision)
56
+
57
+
58
+ def human_frames(ep: Episode, cfg, frames: list[int], view: str | None = None) -> list[np.ndarray]:
59
+ """The human frames at ``frames``, cropped and resized as the image model sees them."""
60
+ c, reader = cfg.image, FrameReader()
61
+ return [load_view_frame(reader, ep, view or ep.views[0], t, int(c.long_edge),
62
+ tuple(c.aspect) if c.get("aspect") else None,
63
+ c.get("crop_anchor", "bottom")).image for t in frames]
64
+
65
+
66
+ def keyframe_grid(human: list[np.ndarray], robot: list[np.ndarray], frames: list[int],
67
+ cols: int = 4, width: int = 384) -> Image.Image:
68
+ """Human keyframes above their translations, ``cols`` keyframes per row pair."""
69
+ h = round(width * human[0].shape[0] / human[0].shape[1])
70
+ rows = (len(frames) + cols - 1) // cols
71
+ n = min(cols, len(frames))
72
+ pad = 4
73
+ grid = Image.new("RGB", (n * width + (n + 1) * pad, rows * (2 * h + pad) + (rows + 1) * pad),
74
+ (255, 255, 255))
75
+ draw = ImageDraw.Draw(grid)
76
+ font = ImageFont.load_default(size=18)
77
+ for i, (a, b, t) in enumerate(zip(human, robot, frames)):
78
+ x = pad + (i % cols) * (width + pad)
79
+ y = pad + (i // cols) * (2 * h + 2 * pad)
80
+ grid.paste(Image.fromarray(a).resize((width, h), Image.LANCZOS), (x, y))
81
+ grid.paste(Image.fromarray(b).resize((width, h), Image.LANCZOS), (x, y + h + pad))
82
+ draw.text((x + 6, y + 4), f"frame {t}", fill=(255, 255, 255), font=font,
83
+ stroke_width=2, stroke_fill=(0, 0, 0))
84
+ return grid
85
+
86
+
87
+ def match_brightness(image: np.ndarray, human: np.ndarray, mask: np.ndarray,
88
+ margin: int = 15) -> np.ndarray:
89
+ """Match the keyframe's lightness (mean and spread of L in Lab) to the human frame.
90
+
91
+ Only the background outside the human-arm mask (grown by ``margin`` px) is compared, so the
92
+ robot itself does not drive the statistics; the same correction is applied to every pixel.
93
+ """
94
+ if human.shape[:2] != image.shape[:2]:
95
+ human = cv2.resize(human, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_AREA)
96
+ if mask.shape[:2] != image.shape[:2]:
97
+ mask = cv2.resize(mask, (image.shape[1], image.shape[0]), interpolation=cv2.INTER_NEAREST)
98
+ k = np.ones((2 * margin + 1, 2 * margin + 1), np.uint8)
99
+ bg = cv2.dilate((mask > 0).astype(np.uint8), k) == 0
100
+ if bg.sum() < 1000:
101
+ return image
102
+ lab = cv2.cvtColor(image, cv2.COLOR_RGB2LAB).astype(np.float32)
103
+ ref = cv2.cvtColor(human, cv2.COLOR_RGB2LAB).astype(np.float32)
104
+ src_l, ref_l = lab[..., 0][bg], ref[..., 0][bg]
105
+ gain = ref_l.std() / max(src_l.std(), 1e-3)
106
+ lab[..., 0] = (lab[..., 0] - src_l.mean()) * gain + ref_l.mean()
107
+ return cv2.cvtColor(np.clip(lab, 0, 255).astype(np.uint8), cv2.COLOR_LAB2RGB)
108
+
109
+
110
+ def h264(src: Path, dst: Path) -> Path:
111
+ subprocess.run(["ffmpeg", "-y", "-loglevel", "error", "-i", str(src), "-c:v", "libx264",
112
+ "-crf", "18", "-pix_fmt", "yuv420p", str(dst)], check=True)
113
+ return dst
114
+
115
+
116
+ class Demo:
117
+ """Both models, kept on the CPU between calls and moved to the GPU for one stage."""
118
+
119
+ def __init__(self, load_models: bool = True):
120
+ self.manifest = load_manifest()
121
+ self.clips = {c["key"]: c for c in self.manifest["clips"]}
122
+ self.embodiments = {e["key"]: e for e in self.manifest["embodiments"]}
123
+ self.episodes = self.manifest["episodes"]
124
+ if not load_models:
125
+ return
126
+ self.prompts = torch.load(ASSETS / "prompts.pt")
127
+ self.negative = torch.load(ASSETS / "negative_prompt.pt")
128
+ self.ckpt = Path(_dir("ROBOTRYON_CKPT", CKPT_REPO, ["image/*", "video/*"]))
129
+ self.flux = _dir("FLUX_DIR", FLUX_REPO, FLUX_FILES, FLUX_REVISION)
130
+ self.ltx = _dir("LTX_DIR", LTX_REPO, LTX_FILES, LTX_REVISION)
131
+ self.image = load_image_pipeline(self.flux, self.ckpt / "image", device="cpu",
132
+ text_encoder=False)
133
+ self.video = iv.load_pipeline(self.video_config([]), views=1, device="cpu")
134
+
135
+ def episode(self, clip: str, emb: str) -> dict:
136
+ return self.episodes[f"{emb}/{clip}"]
137
+
138
+ def image_config(self, overrides: list[str]):
139
+ return load_config(IMAGE_CONFIG, [f"model.base={self.flux}",
140
+ f"checkpoint={self.ckpt / 'image'}", *overrides])
141
+
142
+ def video_config(self, overrides: list[str]):
143
+ return load_config(VIDEO_CONFIG, [f"model.base={self.ltx}",
144
+ f"model.lora={self.ckpt / 'video'}", *overrides])
145
+
146
+ def translate(self, clip: str, emb: str, out_root: str | Path) -> tuple[Path, Image.Image]:
147
+ """Stage 1: keyframe translation. Returns the keyframe directory and the grid."""
148
+ e = self.episode(clip, emb)
149
+ cfg = self.image_config([f"output_dir={out_root}", *e["image_overrides"]])
150
+ ep_dir = HERE / e["dir"]
151
+ ep = Episode.load(ep_dir)
152
+ ref = np.asarray(Image.open(HERE / self.embodiments[emb]["reference"]).convert("RGB"))
153
+ self.image.to("cuda")
154
+ try:
155
+ tr = KeyframeTranslator(self.image, cfg)
156
+ tr._prompts.update({k: tuple(x.to("cuda") for x in self.prompts[k])
157
+ for k in e["prompts"]})
158
+ kf_dir = tr.translate_episode(ep_dir, {v: ref for v in ep.views}, out_root)
159
+ finally:
160
+ self.image.to("cpu")
161
+ torch.cuda.empty_cache()
162
+ frames = json.loads((kf_dir / "keyframes.json").read_text())["indices"]
163
+ if self.clips[clip]["source"] != "self-recorded":
164
+ # downloaded human videos (VITRA, EgoDex): the generated keyframes drift in overall
165
+ # brightness, so match each one to its human frame before stage 2 reads it
166
+ for v in ep.views:
167
+ human = human_frames(ep, cfg, frames, v)
168
+ for t, h in zip(frames, human):
169
+ f = kf_dir / f"keyframe_{v}_{t:06d}.png"
170
+ mask = np.asarray(Image.open(kf_dir / f"mask_{v}_{t:06d}.png").convert("L"))
171
+ img = np.asarray(Image.open(f).convert("RGB"))
172
+ Image.fromarray(match_brightness(img, h, mask)).save(f)
173
+ view = ep.views[0]
174
+ robot = [np.asarray(Image.open(kf_dir / f"keyframe_{view}_{t:06d}.png").convert("RGB"))
175
+ for t in frames]
176
+ grid = keyframe_grid(human_frames(ep, cfg, frames), robot, frames)
177
+ grid.save(kf_dir / "grid.jpg", quality=92)
178
+ return kf_dir, grid
179
+
180
+ def synthesize(self, clip: str, emb: str, kf_dir: str | Path, out_dir: str | Path) -> Path:
181
+ """Stage 2: video synthesis from the stage-1 keyframes. Returns an h264 mp4."""
182
+ e = self.episode(clip, emb)
183
+ cfg = self.video_config([f"input.episode={HERE / e['dir']}",
184
+ f"input.keyframes_dir={kf_dir}", f"output.dir={out_dir}"])
185
+ pe, pm = torch.load(HERE / self.clips[clip]["caption_embeds"])
186
+ text = (pe, pm, *self.negative)
187
+ self.video.to("cuda")
188
+ try:
189
+ out = iv.synthesize(cfg, self.video, text)
190
+ finally:
191
+ self.video.to("cpu")
192
+ torch.cuda.empty_cache()
193
+ view = json.loads((out / "meta.json").read_text())["views"][0]
194
+ return h264(out / f"video_{view}.mp4", out / "robot.mp4")
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ torch==2.8.0
2
+ torchvision==0.23.0
3
+ diffusers @ git+https://github.com/huggingface/diffusers@90b4e34e79a86ec5e7f2437634fe95ecd2108796
4
+ transformers==5.15.0
5
+ peft==0.20.0
6
+ accelerate==1.14.0
7
+ safetensors
8
+ huggingface_hub
9
+ numpy<2
10
+ scipy
11
+ opencv-python-headless
12
+ pillow
13
+ pyyaml
14
+ tqdm
robotryon/__init__.py ADDED
File without changes
robotryon/captions.py ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Text prompts: per-hand state words and the embodiment phrase.
2
+
3
+ Image model. A keyframe's prompt states two facts per hand that a 2D skeleton conveys weakly at
4
+ the sizes hands occupy in the frame -- how closed the hand is and which side faces the camera::
5
+
6
+ Match the hand pose to the drawn skeleton.
7
+ Left hand: open, palm toward camera. Right hand: closed fist, back of hand toward camera.
8
+
9
+ ``closure`` is the MCP + PIP flexion averaged over the four fingers (radians);
10
+ ``dorsumness`` is 180 minus the angle between the knuckle-plane normal and the ray from the hand
11
+ to the camera (degrees, high = back of the hand toward the lens). Both are read from the
12
+ 3D hand in the camera frame -- the reconstructed human hand, or the robot hand from forward
13
+ kinematics, with the same formula -- and cut into words (``HandStateCuts``). A hand that is not
14
+ tracked is ``not visible``.
15
+
16
+ Each episode stores both values per view and frame in ``hand_state_<view>.npz`` (``closure``,
17
+ ``dorsumness``, ``(T, 2)`` float32, NaN where a hand is not tracked), written by the preprocessing
18
+ for human clips (reconstructed hands) and robot episodes (forward kinematics), so training and
19
+ inference read the same quantity.
20
+
21
+ Video model. The prompt is the task caption (``text.template``).
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ from dataclasses import dataclass
27
+
28
+ import numpy as np
29
+
30
+ INSTRUCTION = "Match the hand pose to the drawn skeleton."
31
+ CLOSURE = ("open", "half-closed", "closed fist")
32
+ FACING = ("palm toward camera", "edge-on", "back of hand toward camera")
33
+ NOT_VISIBLE = "not visible"
34
+
35
+ # MediaPipe / MANO 21-joint layout: (MCP, PIP, DIP, tip) per finger.
36
+ _CHAINS = {"index": (5, 6, 7, 8), "middle": (9, 10, 11, 12), "ring": (13, 14, 15, 16),
37
+ "little": (17, 18, 19, 20)}
38
+
39
+
40
+ def _angle(a, b, c) -> float:
41
+ """Flexion at b: 0 when a-b-c is straight."""
42
+ u, v = a - b, c - b
43
+ u = u / max(np.linalg.norm(u), 1e-9)
44
+ v = v / max(np.linalg.norm(v), 1e-9)
45
+ return float(np.pi - np.arccos(np.clip(np.dot(u, v), -1.0, 1.0)))
46
+
47
+
48
+ def hand_fields(j21: np.ndarray, hand: str) -> tuple[float, float]:
49
+ """``(21, 3)`` joints of one hand in the camera frame (camera at the origin, looking down +z)
50
+ -> ``(closure, dorsumness)``; NaN when the hand is not finite."""
51
+ j = np.asarray(j21, np.float64)
52
+ if not np.isfinite(j).all():
53
+ return float("nan"), float("nan")
54
+ w = j[0]
55
+ closure = np.mean([_angle(w, j[a], j[b]) + _angle(j[a], j[b], j[c])
56
+ for a, b, c, _ in _CHAINS.values()])
57
+ n = np.cross(j[5] - w, j[17] - w)
58
+ ray = -j.mean(0) # hand -> camera
59
+ nn, nr = np.linalg.norm(n), np.linalg.norm(ray)
60
+ if nn < 1e-9 or nr < 1e-9:
61
+ return float(closure), float("nan")
62
+ d = 180.0 - float(np.degrees(np.arccos(np.clip(np.dot(n, ray) / (nn * nr), -1.0, 1.0))))
63
+ # the normal leaves through the palm on the right hand and the back on the left
64
+ return float(closure), (d if hand == "left" else 180.0 - d)
65
+
66
+
67
+ @dataclass
68
+ class HandStateCuts:
69
+ """Edges that turn ``closure`` / ``dorsumness`` into words (lower, upper)."""
70
+ closure: tuple[float, float] = (0.95, 1.15)
71
+ facing: tuple[float, float] = (49.0, 121.0)
72
+
73
+ @classmethod
74
+ def from_config(cls, cfg) -> "HandStateCuts":
75
+ cfg = cfg or {}
76
+ return cls(tuple(float(x) for x in cfg.get("closure", cls.closure)),
77
+ tuple(float(x) for x in cfg.get("facing", cls.facing)))
78
+
79
+ def words(self, closure: float, dorsumness: float) -> str:
80
+ if not (np.isfinite(closure) and np.isfinite(dorsumness)):
81
+ return NOT_VISIBLE
82
+ ci = int(closure >= self.closure[0]) + int(closure >= self.closure[1])
83
+ fi = int(dorsumness >= self.facing[0]) + int(dorsumness >= self.facing[1])
84
+ return f"{CLOSURE[ci]}, {FACING[fi]}"
85
+
86
+
87
+ def hand_state_prompt(left: str, right: str) -> str:
88
+ """The image prompt from the two hands' words."""
89
+ return f"{INSTRUCTION} Left hand: {left}. Right hand: {right}."
90
+
91
+
92
+ def all_prompts() -> list[str]:
93
+ """Every prompt training can produce: each pair of hand descriptions, the hand-dropout prompt
94
+ (the instruction alone) and the empty (unconditional) prompt."""
95
+ cells = [f"{c}, {f}" for c in CLOSURE for f in FACING] + [NOT_VISIBLE]
96
+ return [hand_state_prompt(a, b) for a in cells for b in cells] + [INSTRUCTION, ""]
97
+
98
+
99
+ def sample_prompt(words: tuple[str, str], rng: np.random.Generator, p_uncond: float,
100
+ p_drop: float) -> str:
101
+ """Training prompt: empty with ``p_uncond``, hand words dropped with ``p_drop``."""
102
+ u = float(rng.random())
103
+ if u < p_uncond:
104
+ return ""
105
+ if u < p_uncond + p_drop:
106
+ return INSTRUCTION
107
+ return hand_state_prompt(*words)
108
+
109
+
110
+ def hand_state_arrays(joints3d: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
111
+ """``(T, 2, 21|17, 3)`` camera-frame joints and ``(T, 2)`` validity -> ``closure`` and
112
+ ``dorsumness`` ``(T, 2)`` float32 (NaN where invalid)."""
113
+ from .embodiment.retarget import to21
114
+ j = to21(np.asarray(joints3d, np.float64))
115
+ T = j.shape[0]
116
+ clo = np.full((T, 2), np.nan, np.float32)
117
+ dor = np.full((T, 2), np.nan, np.float32)
118
+ for t in range(T):
119
+ for h, name in enumerate(("left", "right")):
120
+ if valid[t, h]:
121
+ clo[t, h], dor[t, h] = hand_fields(j[t, h], name)
122
+ return clo, dor
123
+
124
+
125
+ def save_hand_state(path, closure: np.ndarray, dorsumness: np.ndarray) -> None:
126
+ np.savez_compressed(path, closure=closure.astype(np.float32), dorsumness=dorsumness.astype(np.float32))
127
+
128
+
129
+ def load_hand_state(path) -> tuple[np.ndarray, np.ndarray]:
130
+ z = np.load(path)
131
+ return z["closure"], z["dorsumness"]
132
+
133
+
134
+ def words_at(states: list[tuple[np.ndarray, np.ndarray]], t: int,
135
+ cuts: HandStateCuts) -> tuple[str, str]:
136
+ """Words of both hands at frame ``t`` from one or more views' hand states (averaged)."""
137
+ out = []
138
+ for h in range(2):
139
+ vals = [(c[t, h], d[t, h]) for c, d in states
140
+ if t < len(c) and np.isfinite(c[t, h]) and np.isfinite(d[t, h])]
141
+ if not vals:
142
+ out.append(NOT_VISIBLE)
143
+ continue
144
+ cl, dn = np.mean(np.asarray(vals, np.float64), axis=0)
145
+ out.append(cuts.words(float(cl), float(dn)))
146
+ return out[0], out[1]
robotryon/common/__init__.py ADDED
File without changes
robotryon/common/config.py ADDED
@@ -0,0 +1,115 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """YAML configs with dotted command-line overrides.
2
+
3
+ Every script takes ``--config path.yaml`` followed by any number of ``key.sub=value``
4
+ overrides, e.g.::
5
+
6
+ python scripts/train_image.py --config configs/image/klein9b_lora.yaml \
7
+ train.max_steps=20 data.root=/path/to/data
8
+
9
+ A config may name a parent with ``_base_: other.yaml`` (relative to itself); the child is merged
10
+ on top of it. Values are parsed with YAML rules, so ``1e-5``, ``true``, ``[1, 2]`` and ``null``
11
+ work as expected on the command line.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import argparse
17
+ import copy
18
+ from pathlib import Path
19
+ from typing import Any, Sequence
20
+
21
+ import yaml
22
+
23
+
24
+ class Config(dict):
25
+ """A dict whose keys are also attributes (recursively)."""
26
+
27
+ def __getattr__(self, key: str) -> Any:
28
+ try:
29
+ return self[key]
30
+ except KeyError as e:
31
+ raise AttributeError(key) from e
32
+
33
+ def __setattr__(self, key: str, value: Any) -> None:
34
+ self[key] = value
35
+
36
+ def get_path(self, dotted: str, default: Any = None) -> Any:
37
+ node: Any = self
38
+ for k in dotted.split("."):
39
+ if not isinstance(node, dict) or k not in node:
40
+ return default
41
+ node = node[k]
42
+ return node
43
+
44
+ def to_dict(self) -> dict:
45
+ return _plain(self)
46
+
47
+
48
+ def _wrap(x: Any) -> Any:
49
+ if isinstance(x, dict):
50
+ return Config({k: _wrap(v) for k, v in x.items()})
51
+ if isinstance(x, list):
52
+ return [_wrap(v) for v in x]
53
+ return x
54
+
55
+
56
+ def _plain(x: Any) -> Any:
57
+ if isinstance(x, dict):
58
+ return {k: _plain(v) for k, v in x.items()}
59
+ if isinstance(x, list):
60
+ return [_plain(v) for v in x]
61
+ return x
62
+
63
+
64
+ def merge(base: dict, over: dict) -> dict:
65
+ out = copy.deepcopy(base)
66
+ for k, v in over.items():
67
+ if isinstance(v, dict) and isinstance(out.get(k), dict):
68
+ out[k] = merge(out[k], v)
69
+ else:
70
+ out[k] = copy.deepcopy(v)
71
+ return out
72
+
73
+
74
+ def _set_dotted(d: dict, dotted: str, value: Any) -> None:
75
+ keys = dotted.split(".")
76
+ for k in keys[:-1]:
77
+ d = d.setdefault(k, {})
78
+ d[keys[-1]] = value
79
+
80
+
81
+ def _parse_value(raw: str) -> Any:
82
+ v = yaml.safe_load(raw)
83
+ if isinstance(v, str): # YAML 1.1 reads "1e-5" as a string; accept it as a float
84
+ try:
85
+ return float(v)
86
+ except ValueError:
87
+ pass
88
+ return v
89
+
90
+
91
+ def load_config(path: str | Path, overrides: Sequence[str] = ()) -> Config:
92
+ path = Path(path)
93
+ cfg = yaml.safe_load(path.read_text()) or {}
94
+ base = cfg.pop("_base_", None)
95
+ if base is not None:
96
+ cfg = merge(load_config(path.parent / base).to_dict(), cfg)
97
+ for item in overrides:
98
+ if "=" not in item:
99
+ raise ValueError(f"override must be key=value, got {item!r}")
100
+ key, raw = item.split("=", 1)
101
+ _set_dotted(cfg, key, _parse_value(raw))
102
+ return _wrap(cfg)
103
+
104
+
105
+ def parse_args(description: str = "", argv: Sequence[str] | None = None) -> Config:
106
+ """``--config file.yaml [key=value ...]`` -> config."""
107
+ p = argparse.ArgumentParser(description=description)
108
+ p.add_argument("--config", required=True, help="YAML config file")
109
+ p.add_argument("overrides", nargs="*", help="dotted overrides, e.g. train.lr=1e-5")
110
+ a = p.parse_args(argv)
111
+ return load_config(a.config, a.overrides)
112
+
113
+
114
+ def save_config(cfg: dict, path: str | Path) -> None:
115
+ Path(path).write_text(yaml.safe_dump(_plain(cfg), sort_keys=False))
robotryon/embodiment/__init__.py ADDED
@@ -0,0 +1,25 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robot embodiments: kinematics, cameras, silhouettes and hand retargeting from a YAML spec.
2
+
3
+ ``load_embodiment("my_robot.yaml")`` returns a :class:`MujocoEmbodiment`; see
4
+ ``configs/embodiments/gr1.yaml`` for every field.
5
+ """
6
+
7
+ from .base import OFFSCREEN_TOLERANCE, Embodiment
8
+ from .camera import CameraModel
9
+ from .spec import EmbodimentSpec, load_spec, parse_spec
10
+
11
+
12
+ def load_embodiment(path_or_name):
13
+ """Embodiment from a YAML path, or from ``configs/embodiments/<name>.yaml`` (relative to
14
+ the working directory) when given a bare name."""
15
+ from pathlib import Path
16
+
17
+ from .mujoco_embodiment import MujocoEmbodiment # mujoco is an optional dependency
18
+ p = Path(path_or_name)
19
+ if not p.exists() and p.suffix == "":
20
+ p = Path("configs/embodiments") / f"{path_or_name}.yaml"
21
+ return MujocoEmbodiment(load_spec(p))
22
+
23
+
24
+ __all__ = ["Embodiment", "CameraModel", "EmbodimentSpec", "OFFSCREEN_TOLERANCE",
25
+ "load_embodiment", "load_spec", "parse_spec"]
robotryon/embodiment/base.py ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The interface every robot is used through.
2
+
3
+ The generative models never see joint angles, only images, a mask of the arm and hand, and a
4
+ 17-keypoint hand skeleton. What a robot must provide is therefore small:
5
+
6
+ * ``fk(q)``: wrist pose and the 17 hand keypoints of each hand in the robot base frame;
7
+ * ``camera_pose(q, view)`` and ``project(points, view)``: the head camera(s), to draw the
8
+ skeleton of a robot frame and to place a human hand recorded from the same viewpoint;
9
+ * ``silhouette(q, view)``: the rendered robot, which together with SAM3 gives the robot mask;
10
+ * ``retarget_hand(human_kp3d)``: the robot hand posed like a reconstructed human hand.
11
+
12
+ Arrays over hands always use the order ``(left, right)``; a hand the robot does not have is NaN.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from abc import ABC, abstractmethod
18
+
19
+ import numpy as np
20
+
21
+ from robotryon.skeleton.topology import NUM_HANDS, NUM_KEYPOINTS
22
+
23
+ from .spec import HANDS, EmbodimentSpec
24
+
25
+ # A keypoint off screen is still drawn (OpenCV clips the bone) unless it lies further than
26
+ # this many frame sizes outside the image, where the projection is degenerate.
27
+ OFFSCREEN_TOLERANCE = 3.0
28
+
29
+
30
+ class Embodiment(ABC):
31
+ spec: EmbodimentSpec
32
+
33
+ @property
34
+ def name(self) -> str:
35
+ return self.spec.name
36
+
37
+ @property
38
+ def views(self) -> list[str]:
39
+ return self.spec.views
40
+
41
+ @property
42
+ def joint_names(self) -> list[str | None]:
43
+ """Layout of the recorded joint vector ``q`` (``None`` = slot not in the model)."""
44
+ return self.spec.joints
45
+
46
+ # --- kinematics ----------------------------------------------------------------------
47
+ @abstractmethod
48
+ def fk(self, q) -> dict[str, dict[str, np.ndarray]]:
49
+ """Forward kinematics in the base frame, metres.
50
+
51
+ Args:
52
+ q: joint vector in ``joint_names`` order, or a ``{joint: value}`` dict.
53
+ Returns:
54
+ ``{hand: {"wrist_pos": (3,), "wrist_rot": (3, 3), "keypoints": (17, 3)}}`` for each
55
+ hand the robot has (``"left"``/``"right"``); keypoint 0 is the wrist and
56
+ ``wrist_rot`` columns are the wrist frame's axes in the base frame.
57
+ """
58
+
59
+ @property
60
+ def arm_joint_slices(self) -> dict[str, slice]:
61
+ """Slice of the joint/action vector holding each arm's joints (YAML ``arm_joints``)."""
62
+ return {h: slice(*r) for h, r in self.spec.arm_joints.items()}
63
+
64
+ def hand_keypoints(self, q) -> np.ndarray:
65
+ """``(2, 17, 3)`` keypoints of ``fk(q)`` in (left, right) order, NaN for a missing hand."""
66
+ out, f = empty_hands(), self.fk(q)
67
+ for hi, h in enumerate(HANDS):
68
+ if h in f:
69
+ out[hi] = f[h]["keypoints"]
70
+ return out
71
+
72
+ @abstractmethod
73
+ def camera_pose(self, q, view: str) -> np.ndarray:
74
+ """4x4 camera-to-base pose of ``view`` (optical frame) at joints ``q``.
75
+
76
+ ``q=None`` uses the default pose (useful for fixed or stereo-relative quantities).
77
+ """
78
+
79
+ @abstractmethod
80
+ def silhouette(self, q, view: str, image_size=None) -> np.ndarray:
81
+ """Rendered robot arm+hand mask, bool ``(H, W)``."""
82
+
83
+ @abstractmethod
84
+ def retarget_hand(self, human_kp3d: np.ndarray, valid=None, prev: dict | None = None):
85
+ """Robot hand posed like a human hand (vector retargeting, see ``retarget``).
86
+
87
+ Args:
88
+ human_kp3d: ``(2, 21|17, 3)`` human joints (MediaPipe order) in any frame.
89
+ valid: ``(2,)`` bool, hands to retarget (default: finite ones).
90
+ prev: the joints returned for the previous frame of the clip; they warm-start the
91
+ optimization and its smoothing term (None for the first frame).
92
+ Returns:
93
+ ``(2, 17, 3)`` robot keypoints in the same frame (NaN for skipped hands) and a
94
+ ``{joint: value}`` dict of the retargeted robot hand joints.
95
+ """
96
+
97
+ # --- cameras -------------------------------------------------------------------------
98
+ def camera(self, view: str):
99
+ return self.spec.cameras[view].intrinsics
100
+
101
+ def stereo_extrinsic(self, view_a: str, view_b: str) -> np.ndarray:
102
+ """4x4 transform from ``view_b``'s camera frame to ``view_a``'s."""
103
+ Ta, Tb = self.camera_pose(None, view_a), self.camera_pose(None, view_b)
104
+ return np.linalg.inv(Ta) @ Tb
105
+
106
+ def project(self, points, view: str, q=None, frame: str = "base", image_size=None):
107
+ """``(..., 3)`` points -> ``(..., 2)`` pixels and ``(...)`` depth-ok mask.
108
+
109
+ ``frame="base"`` needs ``q`` (the camera may move with the head);
110
+ ``frame="camera"`` takes points already in ``view``'s optical frame.
111
+ """
112
+ pts = np.asarray(points, np.float64)
113
+ if frame == "base":
114
+ T = np.linalg.inv(self.camera_pose(q, view))
115
+ pts = pts @ T[:3, :3].T + T[:3, 3]
116
+ elif frame != "camera":
117
+ raise ValueError(f"frame must be 'base' or 'camera', got {frame!r}")
118
+ return self.camera(view).project(pts, image_size)
119
+
120
+ def keypoints_2d(self, points, view: str, image_size, q=None, frame: str = "base"):
121
+ """Project ``(2, 17, 3)`` keypoints into the skeleton format.
122
+
123
+ Returns ``kp`` float32 ``(2, 17, 2)`` (0 where invisible) and ``vis`` bool ``(2, 17)``:
124
+ in front of the camera and within ``OFFSCREEN_TOLERANCE`` frames of the image.
125
+ """
126
+ px, ok = self.project(points, view, q=q, frame=frame, image_size=image_size)
127
+ W, H = image_size
128
+ m = OFFSCREEN_TOLERANCE
129
+ u, v = px[..., 0], px[..., 1]
130
+ with np.errstate(invalid="ignore"):
131
+ vis = ok & (u > -m * W) & (u < (1 + m) * W) & (v > -m * H) & (v < (1 + m) * H)
132
+ kp = np.where(vis[..., None], px, 0.0).astype(np.float32)
133
+ return kp, vis
134
+
135
+ def keypoints_in_view(self, q, view: str, image_size):
136
+ """Projected skeleton ``(kp, vis)`` of the robot at joints ``q`` (see keypoints_2d)."""
137
+ return self.keypoints_2d(self.hand_keypoints(q), view, image_size, q=q)
138
+
139
+
140
+ def empty_hands(d: int = 3) -> np.ndarray:
141
+ return np.full((NUM_HANDS, NUM_KEYPOINTS, d), np.nan)
142
+
143
+
144
+ __all__ = ["Embodiment", "HANDS", "OFFSCREEN_TOLERANCE", "empty_hands"]
robotryon/embodiment/camera.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Camera intrinsics: projection of camera-frame points to pixels and back to rays.
2
+
3
+ A camera is calibrated at one resolution (``width`` x ``height``); every method takes the
4
+ image size it should work at and rescales the intrinsics, so the same calibration serves a
5
+ video stored at any resolution with the same aspect. Points are in the OpenCV optical frame
6
+ (x right, y down, z forward). Two lens models are supported:
7
+
8
+ * ``pinhole``: OpenCV plumb-bob distortion ``[k1, k2, p1, p2, k3]`` (empty = rectified).
9
+ * ``fisheye``: OpenCV equidistant model ``[k1, k2, k3, k4]`` (zeros = ideal equidistant).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from dataclasses import dataclass, field
15
+
16
+ import cv2
17
+ import numpy as np
18
+
19
+
20
+ @dataclass
21
+ class CameraModel:
22
+ width: int
23
+ height: int
24
+ fx: float
25
+ fy: float
26
+ cx: float
27
+ cy: float
28
+ model: str = "pinhole"
29
+ dist: tuple = ()
30
+ # Additive pixel offset applied after projection (a fitted principal-point residual),
31
+ # given at the calibration resolution.
32
+ pp_offset: tuple = (0.0, 0.0)
33
+ # Points closer than this to the lens are inside the robot's own head and are not drawn.
34
+ min_depth: float = 0.15
35
+ extra: dict = field(default_factory=dict)
36
+
37
+ def __post_init__(self):
38
+ if self.model not in ("pinhole", "fisheye"):
39
+ raise ValueError(f"unknown camera model {self.model!r}")
40
+
41
+ @classmethod
42
+ def from_dict(cls, d: dict) -> "CameraModel":
43
+ known = {"width", "height", "fx", "fy", "cx", "cy", "model", "dist", "pp_offset",
44
+ "min_depth"}
45
+ kw = {k: d[k] for k in known if k in d}
46
+ kw["dist"] = tuple(float(x) for x in d.get("dist", ()) or ())
47
+ kw["pp_offset"] = tuple(float(x) for x in d.get("pp_offset", (0.0, 0.0)))
48
+ return cls(**kw, extra={k: v for k, v in d.items() if k not in known})
49
+
50
+ # --- intrinsics at a given resolution -------------------------------------------------
51
+ def _scale(self, image_size):
52
+ if image_size is None:
53
+ return 1.0, 1.0
54
+ return image_size[0] / self.width, image_size[1] / self.height
55
+
56
+ def K(self, image_size=None) -> np.ndarray:
57
+ sx, sy = self._scale(image_size)
58
+ return np.array([[self.fx * sx, 0.0, self.cx * sx],
59
+ [0.0, self.fy * sy, self.cy * sy],
60
+ [0.0, 0.0, 1.0]])
61
+
62
+ def D(self) -> np.ndarray:
63
+ n = 4 if self.model == "fisheye" else 5
64
+ d = np.zeros(n)
65
+ d[:len(self.dist)] = self.dist
66
+ return d
67
+
68
+ def offset(self, image_size=None) -> np.ndarray:
69
+ sx, sy = self._scale(image_size)
70
+ return np.array([self.pp_offset[0] * sx, self.pp_offset[1] * sy])
71
+
72
+ # --- projection ------------------------------------------------------------------------
73
+ def project(self, pts_cam: np.ndarray, image_size=None):
74
+ """``(..., 3)`` camera-frame points -> ``(..., 2)`` pixels and ``(...)`` depth-ok mask.
75
+
76
+ Points behind ``min_depth`` get NaN pixels.
77
+ """
78
+ pts = np.asarray(pts_cam, np.float64)
79
+ shape = pts.shape[:-1]
80
+ flat = pts.reshape(-1, 3)
81
+ ok = np.isfinite(flat).all(1) & (flat[:, 2] > self.min_depth)
82
+ px = np.full((flat.shape[0], 2), np.nan)
83
+ if ok.any():
84
+ obj = flat[ok].reshape(-1, 1, 3)
85
+ K, D = self.K(image_size), self.D()
86
+ if self.model == "fisheye":
87
+ uv, _ = cv2.fisheye.projectPoints(obj, np.zeros(3), np.zeros(3), K, D)
88
+ else:
89
+ uv, _ = cv2.projectPoints(obj, np.zeros(3), np.zeros(3), K, D)
90
+ px[ok] = uv.reshape(-1, 2) + self.offset(image_size)
91
+ return px.reshape(shape + (2,)), ok.reshape(shape)
92
+
93
+ def normalized(self, pixels: np.ndarray, image_size=None) -> np.ndarray:
94
+ """``(..., 2)`` pixels -> ``(..., 2)`` undistorted normalized coordinates (x/z, y/z)."""
95
+ uv = np.asarray(pixels, np.float64)
96
+ shape = uv.shape[:-1]
97
+ uv = (uv.reshape(-1, 2) - self.offset(image_size)).reshape(-1, 1, 2)
98
+ K, D = self.K(image_size), self.D()
99
+ if self.model == "fisheye":
100
+ n = cv2.fisheye.undistortPoints(uv, K, D)
101
+ else:
102
+ n = cv2.undistortPoints(uv, K, D)
103
+ return n.reshape(shape + (2,))
robotryon/embodiment/mujoco_embodiment.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """MuJoCo implementation of :class:`Embodiment`, generic over the YAML description.
2
+
3
+ The robot model (MJCF, or URDF which MuJoCo also parses) is supplied by the user; nothing
4
+ robot-specific is coded here. Forward kinematics is ``mj_kinematics`` on a pose built from
5
+ the recorded joint vector: unlisted joints stay at ``qpos0``, coupled passive joints are
6
+ filled from their sources and ``fixed_joints`` are applied last. A head camera mounted on a
7
+ model frame therefore follows the recorded head joints.
8
+
9
+ Pin ``mujoco==3.11.0``: with 3.12 site-based camera poses silently collapse (every point
10
+ lands at camera depth ~0), so a different version raises unless
11
+ ``ROBOTRYON_ALLOW_ANY_MUJOCO=1``.
12
+
13
+ Rendering (``silhouette``) needs an OpenGL backend; set ``MUJOCO_GL=egl`` (GPU) or
14
+ ``MUJOCO_GL=osmesa`` (CPU) before the first import of mujoco.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import os
20
+
21
+ import cv2
22
+ import numpy as np
23
+
24
+ from . import retarget as RT
25
+ from .base import Embodiment, empty_hands
26
+ from .spec import HANDS, EmbodimentSpec, load_spec
27
+
28
+ MUJOCO_PIN = "3.11.0"
29
+ _SIL_HIDDEN_GROUP = 5
30
+
31
+
32
+ def _import_mujoco():
33
+ import mujoco
34
+ if mujoco.__version__ != MUJOCO_PIN and os.environ.get("ROBOTRYON_ALLOW_ANY_MUJOCO") != "1":
35
+ raise RuntimeError(
36
+ f"mujoco {mujoco.__version__} != {MUJOCO_PIN}: newer versions break site-based "
37
+ f"camera poses without an error. Install mujoco=={MUJOCO_PIN} or set "
38
+ f"ROBOTRYON_ALLOW_ANY_MUJOCO=1.")
39
+ return mujoco
40
+
41
+
42
+ class MujocoEmbodiment(Embodiment):
43
+ def __init__(self, spec: EmbodimentSpec | str):
44
+ self.spec = load_spec(spec) if isinstance(spec, (str, os.PathLike)) else spec
45
+ mj = self._mj = _import_mujoco()
46
+ if not os.path.exists(self.spec.model_path):
47
+ raise FileNotFoundError(f"robot model not found: {self.spec.model_path}")
48
+ self.model = mj.MjModel.from_xml_path(self.spec.model_path)
49
+ self.data = mj.MjData(self.model)
50
+
51
+ names = set(n for n in self.spec.joints if n) | set(self.spec.fixed_joints)
52
+ names |= {c[0] for c in self.spec.coupling} | {c[2] for c in self.spec.coupling}
53
+ names |= {j for js in self.spec.retarget.joints.values() for j in js}
54
+ self._adr = {n: self._joint_adr(n) for n in names}
55
+ self._rt_bounds = {h: self._joint_limits(js) for h, js in self.spec.retarget.joints.items()}
56
+ self._rt_palm: dict[str, float] = {}
57
+ self._kp = {h: [(*self._frame(n), off) for n, off in
58
+ zip(self.spec.keypoints[h], self.spec.keypoint_offsets[h])]
59
+ for h in self.spec.hands}
60
+ # the wrist pose: the explicit wrist_frame, else keypoint 0 (with its offset)
61
+ self._wrist = {h: (*self._frame(self.spec.wrist_frame[h]),
62
+ self.spec.keypoint_offsets[h][0]
63
+ if self.spec.wrist_frame[h] == self.spec.keypoints[h][0] else None)
64
+ for h in self.spec.hands}
65
+ self._mount = {v: (self._frame(c.mount.frame) if c.mount.frame else None)
66
+ for v, c in self.spec.cameras.items()}
67
+ self._renderers: dict = {}
68
+
69
+ # --- name resolution -----------------------------------------------------------------
70
+ def _joint_adr(self, name: str) -> int:
71
+ jid = self._mj.mj_name2id(self.model, self._mj.mjtObj.mjOBJ_JOINT, name)
72
+ if jid < 0:
73
+ raise ValueError(f"joint {name!r} not in {self.spec.model_path}")
74
+ return int(self.model.jnt_qposadr[jid])
75
+
76
+ def _joint_limits(self, names) -> tuple[np.ndarray, np.ndarray]:
77
+ """``(lo, hi)`` of each joint: the model's limits ([-pi, pi] when unlimited), narrowed
78
+ by ``retarget.limits``."""
79
+ lo, hi = [], []
80
+ for n in names:
81
+ jid = self._mj.mj_name2id(self.model, self._mj.mjtObj.mjOBJ_JOINT, n)
82
+ r = self.model.jnt_range[jid]
83
+ ok = bool(self.model.jnt_limited[jid]) and r[1] > r[0]
84
+ a, b = (float(r[0]), float(r[1])) if ok else (-np.pi, np.pi)
85
+ if n in self.spec.retarget.limits:
86
+ ua, ub = self.spec.retarget.limits[n]
87
+ a, b = max(a, ua), min(b, ub)
88
+ if b <= a:
89
+ raise ValueError(f"retarget.limits for {n} [{ua}, {ub}] leaves no range "
90
+ f"inside the model limits")
91
+ lo.append(a)
92
+ hi.append(b)
93
+ return np.asarray(lo), np.asarray(hi)
94
+
95
+ def _frame(self, name: str) -> tuple[str, int]:
96
+ """``site:x`` / ``body:x`` / ``x`` (site first, then body) -> (kind, id)."""
97
+ kind, _, bare = name.rpartition(":")
98
+ T = self._mj.mjtObj
99
+ for k in ([kind] if kind else ["site", "body"]):
100
+ oid = self._mj.mj_name2id(self.model, T.mjOBJ_SITE if k == "site" else T.mjOBJ_BODY,
101
+ bare)
102
+ if oid >= 0:
103
+ return k, oid
104
+ raise ValueError(f"frame {name!r} not in {self.spec.model_path}")
105
+
106
+ def _pos(self, f):
107
+ p = (self.data.site_xpos if f[0] == "site" else self.data.xpos)[f[1]].copy()
108
+ if len(f) > 2 and f[2] is not None: # point fixed in the frame
109
+ p = p + self._rot(f) @ f[2]
110
+ return p
111
+
112
+ def _rot(self, f):
113
+ m = self.data.site_xmat if f[0] == "site" else self.data.xmat
114
+ return m[f[1]].reshape(3, 3).copy()
115
+
116
+ # --- kinematics ----------------------------------------------------------------------
117
+ def joint_dict(self, q) -> dict[str, float]:
118
+ """Joint vector (``spec.joints`` order) or dict -> complete ``{joint: value}``."""
119
+ if q is None:
120
+ d = {}
121
+ elif isinstance(q, dict):
122
+ d = {k: float(v) for k, v in q.items()}
123
+ else:
124
+ q = np.asarray(q, np.float64).reshape(-1)
125
+ if len(q) != len(self.spec.joints):
126
+ raise ValueError(f"expected {len(self.spec.joints)} joints, got {len(q)}")
127
+ d = {n: float(v) * self.spec.joint_scale.get(n, 1.0) for n, v in zip(self.spec.joints, q) if n}
128
+ for joint, ratio, src in self.spec.coupling:
129
+ if src in d:
130
+ d[joint] = ratio * d[src]
131
+ d.update(self.spec.fixed_joints)
132
+ return d
133
+
134
+ def set_state(self, q) -> None:
135
+ self.data.qpos[:] = self.model.qpos0
136
+ for n, v in self.joint_dict(q).items():
137
+ adr = self._adr.get(n)
138
+ if adr is None:
139
+ adr = self._adr[n] = self._joint_adr(n)
140
+ self.data.qpos[adr] = v
141
+ self._mj.mj_kinematics(self.model, self.data)
142
+
143
+ def _hand_points(self) -> np.ndarray:
144
+ """Keypoints of the current state, base frame, with the per-finger length scale.
145
+
146
+ The scale pulls each finger's distal points toward its knuckle
147
+ (``p <- p_knuckle + s * (p - p_knuckle)``), a fitted correction for model fingers that
148
+ render longer than the real hand.
149
+ """
150
+ out = empty_hands()
151
+ for hi, h in enumerate(HANDS):
152
+ if h not in self._kp:
153
+ continue
154
+ p = np.stack([self._pos(f) for f in self._kp[h]])
155
+ for finger, (k, distal) in RT.CHAINS17.items():
156
+ s = self.spec.finger_scale.get(finger)
157
+ if s is not None:
158
+ p[list(distal)] = p[k] + s * (p[list(distal)] - p[k])
159
+ out[hi] = p
160
+ return out
161
+
162
+ def fk(self, q) -> dict[str, dict[str, np.ndarray]]:
163
+ self.set_state(q)
164
+ kp = self._hand_points()
165
+ return {h: {"wrist_pos": self._pos(self._wrist[h]), "wrist_rot": self._rot(self._wrist[h]),
166
+ "keypoints": kp[HANDS.index(h)]}
167
+ for h in HANDS if h in self._wrist}
168
+
169
+ def hand_keypoints(self, q) -> np.ndarray:
170
+ self.set_state(q)
171
+ return self._hand_points()
172
+
173
+ def _camera_pose_now(self, view: str) -> np.ndarray:
174
+ m = self.spec.cameras[view].mount
175
+ if m.extrinsic is not None:
176
+ return m.extrinsic.copy()
177
+ f = self._mount[view]
178
+ R_frame = self._rot(f)
179
+ T = np.eye(4)
180
+ T[:3, :3] = R_frame @ m.rotation.T
181
+ T[:3, 3] = self._pos(f) + R_frame @ m.translation
182
+ return T
183
+
184
+ def camera_pose(self, q, view: str) -> np.ndarray:
185
+ self.set_state(q)
186
+ return self._camera_pose_now(view)
187
+
188
+ def keypoints_in_view(self, q, view: str, image_size):
189
+ """One FK pass -> projected skeleton ``kp (2,17,2)``, ``vis (2,17)`` of a robot frame."""
190
+ self.set_state(q)
191
+ T = np.linalg.inv(self._camera_pose_now(view))
192
+ pts = self._hand_points() @ T[:3, :3].T + T[:3, 3]
193
+ return self.keypoints_2d(pts, view, image_size, frame="camera")
194
+
195
+ # --- rendering -----------------------------------------------------------------------
196
+ def _renderer(self, view: str, image_size, scale: float):
197
+ key = (view, tuple(image_size), scale)
198
+ if key in self._renderers:
199
+ return self._renderers[key]
200
+ mj, model = self._mj, self.model
201
+ inc, exc = tuple(self.spec.silhouette_bodies), tuple(self.spec.silhouette_exclude)
202
+ for g in range(model.ngeom):
203
+ body = mj.mj_id2name(model, mj.mjtObj.mjOBJ_BODY, int(model.geom_bodyid[g])) or ""
204
+ show = body.startswith(inc) and not (exc and body.startswith(exc))
205
+ model.geom_group[g] = 0 if show else _SIL_HIDDEN_GROUP
206
+ # Render a symmetric wide pinhole, then remap each output pixel through the real
207
+ # camera's (distorted, off-centre) ray so the silhouette matches the projection.
208
+ W, H = image_size
209
+ w, h = max(8, round(W * scale)), max(8, round(H * scale))
210
+ cam = self.camera(view)
211
+ u, v = np.meshgrid((np.arange(w) + 0.5) * W / w - 0.5, (np.arange(h) + 0.5) * H / h - 0.5)
212
+ n = cam.normalized(np.stack([u, v], -1), image_size)
213
+ f = cam.K(image_size)[1, 1] * h / H
214
+ ext = np.abs(n.reshape(-1, 2)).max(0) * 1.03
215
+ Wp, Hp = int(2 * np.ceil(ext[0] * f)) + 2, int(2 * np.ceil(ext[1] * f)) + 2
216
+ mx = (n[..., 0] * f + Wp / 2.0 - 0.5).astype(np.float32)
217
+ my = (n[..., 1] * f + Hp / 2.0 - 0.5).astype(np.float32)
218
+ model.vis.global_.offwidth = max(int(model.vis.global_.offwidth), Wp)
219
+ model.vis.global_.offheight = max(int(model.vis.global_.offheight), Hp)
220
+ model.vis.map.znear = 0.001
221
+ model.vis.quality.offsamples = 0 # MSAA would blend segmentation ids
222
+ r = mj.Renderer(model, height=Hp, width=Wp)
223
+ r.enable_segmentation_rendering()
224
+ opt = mj.MjvOption()
225
+ opt.geomgroup[:] = 0
226
+ opt.geomgroup[0] = 1
227
+ opt.sitegroup[:] = 0
228
+ mcam = mj.MjvCamera()
229
+ mj.mjv_defaultCamera(mcam)
230
+ self._renderers[key] = (r, opt, mcam, mx, my, f, Hp)
231
+ return self._renderers[key]
232
+
233
+ def silhouette(self, q, view: str, image_size=None, scale: float = 0.5) -> np.ndarray:
234
+ cam = self.camera(view)
235
+ image_size = tuple(image_size or (cam.width, cam.height))
236
+ r, opt, mcam, mx, my, f, Hp = self._renderer(view, image_size, scale)
237
+ self.set_state(q)
238
+ T = self._camera_pose_now(view)
239
+ r.update_scene(self.data, camera=mcam, scene_option=opt)
240
+ near, far = 0.02, 6.0
241
+ half_h = near * (Hp / 2.0) / f
242
+ for gc in r.scene.camera:
243
+ gc.pos[:] = T[:3, 3]
244
+ gc.forward[:] = T[:3, 2]
245
+ gc.up[:] = -T[:3, 1]
246
+ gc.frustum_center = 0.0
247
+ gc.frustum_bottom, gc.frustum_top = -half_h, half_h
248
+ gc.frustum_near, gc.frustum_far = near, far
249
+ gc.orthographic = 0
250
+ seg = r.render()[:, :, 0]
251
+ sil = cv2.remap((seg >= 0).astype(np.uint8), mx, my, cv2.INTER_NEAREST,
252
+ borderMode=cv2.BORDER_CONSTANT, borderValue=0)
253
+ return cv2.resize(sil, image_size, interpolation=cv2.INTER_NEAREST) > 0
254
+
255
+ # --- retargeting ---------------------------------------------------------------------
256
+ def _hand_vectors(self, hand: str, x: np.ndarray):
257
+ """Retargeting vectors (wrist frame) of one robot hand at hand-joint values ``x``."""
258
+ self.set_state(dict(zip(self.spec.retarget.joints[hand], x)))
259
+ return RT.local_vectors(self._hand_points()[HANDS.index(hand)])
260
+
261
+ def _palm(self, hand: str) -> float:
262
+ if hand not in self._rt_palm:
263
+ lo, hi = self._rt_bounds[hand]
264
+ self.set_state(dict(zip(self.spec.retarget.joints[hand], np.clip(0.0, lo, hi))))
265
+ self._rt_palm[hand] = RT.palm_size(self._hand_points()[HANDS.index(hand)])
266
+ return self._rt_palm[hand]
267
+
268
+ def _solve_hand(self, hand: str, h17: np.ndarray, x_prev=None):
269
+ """AnyTeleop vector retargeting of one hand: hand joints (model order) or None."""
270
+ from scipy.optimize import least_squares
271
+
272
+ rt = self.spec.retarget
273
+ target = RT.local_vectors(h17)
274
+ if target is None:
275
+ return None
276
+ scale = rt.scaling if rt.scaling is not None else self._palm(hand) / max(RT.palm_size(h17), 1e-6)
277
+ target = target * scale
278
+ lo, hi = self._rt_bounds[hand]
279
+ x0 = np.clip(np.zeros(len(lo)) if x_prev is None else np.asarray(x_prev, np.float64), lo, hi)
280
+ x0 = np.clip(x0, lo + 1e-9, hi - 1e-9)
281
+ w = np.sqrt(rt.smoothing)
282
+
283
+ def residual(x):
284
+ v = self._hand_vectors(hand, x)
285
+ r = np.zeros(target.size) if v is None else (v - target).ravel()
286
+ return r if x_prev is None else np.concatenate([r, w * (x - x0)])
287
+
288
+ sol = least_squares(residual, x0, bounds=(lo, hi), loss="huber", f_scale=rt.huber_delta,
289
+ diff_step=1e-4, max_nfev=200)
290
+ return sol.x
291
+
292
+ def retarget_hand(self, human_kp3d, valid=None, prev=None):
293
+ j21 = RT.to21(human_kp3d)
294
+ if valid is None:
295
+ valid = np.isfinite(j21).all(axis=(-1, -2))
296
+ rt = self.spec.retarget
297
+ q = {}
298
+ for hi, h in enumerate(HANDS):
299
+ joints = rt.joints.get(h)
300
+ if not valid[hi] or not joints:
301
+ continue
302
+ x_prev = None
303
+ if prev and all(n in prev for n in joints):
304
+ x_prev = [prev[n] for n in joints]
305
+ x = self._solve_hand(h, RT.human17(j21[hi]), x_prev)
306
+ if x is not None:
307
+ q.update({n: float(v) for n, v in zip(joints, x)})
308
+ out = empty_hands()
309
+ if not q:
310
+ return out, q
311
+ robot = self.hand_keypoints(q)
312
+ for hi, h in enumerate(HANDS):
313
+ if valid[hi] and all(n in q for n in rt.joints.get(h) or [None]):
314
+ placed = RT.place_on_human(robot[hi], j21[hi], rt.bone_match)
315
+ if placed is not None:
316
+ out[hi] = placed
317
+ return out, q
318
+
319
+
320
+ __all__ = ["MujocoEmbodiment", "MUJOCO_PIN"]
robotryon/embodiment/retarget.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Retarget a reconstructed human hand onto a robot hand (AnyTeleop vector retargeting).
2
+
3
+ The skeleton that conditions the generative models must look like the target robot's hand,
4
+ so a human hand is not drawn directly. Instead (Appendix B.2.2):
5
+
6
+ 1. Both hands are expressed in a wrist frame (origin at the wrist, x toward the knuckle
7
+ centroid, z along the knuckle-plane normal) built identically from the human joints and
8
+ from the robot's 17 keypoints.
9
+ 2. Following the vector retargeting of AnyTeleop (Qin et al., 2023), the robot hand joints are
10
+ optimized so that the robot's wrist-to-fingertip and wrist-to-middle-joint vectors match the
11
+ human's, scaled by the ratio of the palm sizes, within the joint limits of the robot model,
12
+ with a small penalty on the change from the previous frame (``VECTORS17``,
13
+ ``MujocoEmbodiment.retarget_hand``).
14
+ 3. Robot forward kinematics at those joints gives the robot hand's 17 keypoints. Bone
15
+ matching stretches each robot finger to the human's knuckle distance and finger length
16
+ while keeping the robot's directions (the robot's own curl), so the fingertips reach what
17
+ the human touches.
18
+ 4. The result is placed at the human wrist frame, in the human's camera frame.
19
+
20
+ The frame helpers and the vector set live here; the optimization needs FK and is done by the
21
+ embodiment.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import numpy as np
27
+
28
+ from robotryon.skeleton.topology import MEDIAPIPE21_TO_17
29
+
30
+ # MediaPipe / MANO 21-joint layout: wrist, then four joints per finger, proximal to distal.
31
+ CHAINS21 = {"thumb": (1, 2, 3, 4), "index": (5, 6, 7, 8), "middle": (9, 10, 11, 12),
32
+ "ring": (13, 14, 15, 16), "little": (17, 18, 19, 20)}
33
+ # 17-keypoint layout (robotryon.skeleton.topology): (knuckle, distal points) per finger.
34
+ CHAINS17 = {"thumb": (1, (2, 3, 4)), "index": (5, (6, 7)), "middle": (8, (9, 10)),
35
+ "ring": (11, (12, 13)), "little": (14, (15, 16))}
36
+ FINGERS4 = ("index", "middle", "ring", "little")
37
+
38
+ # Robot wrist frame from the 17-keypoint layout: wrist, index knuckle, little knuckle.
39
+ WRIST17, INDEX17, LITTLE17 = 0, 5, 14
40
+
41
+ # Retargeting vectors as (origin, target) pairs of the 17-keypoint layout: wrist to every
42
+ # fingertip and wrist to the joint before it (thumb distal, index/middle/ring/little middle).
43
+ TIPS17 = tuple(distal[-1] for _, distal in CHAINS17.values())
44
+ MIDS17 = (3, 6, 9, 12, 15)
45
+ VECTORS17 = tuple((WRIST17, t) for t in TIPS17) + tuple((WRIST17, m) for m in MIDS17)
46
+
47
+
48
+ def to21(kp: np.ndarray) -> np.ndarray:
49
+ """``(..., 17|21, 3)`` -> 21 joints. Missing DIP joints are put midway between PIP and
50
+ tip, which leaves every finger length and direction used below unchanged."""
51
+ kp = np.asarray(kp, np.float64)
52
+ if kp.shape[-2] == 21:
53
+ return kp
54
+ out = np.zeros(kp.shape[:-2] + (21, 3))
55
+ out[..., MEDIAPIPE21_TO_17, :] = kp
56
+ for pip, dip, tip in ((6, 7, 8), (10, 11, 12), (14, 15, 16), (18, 19, 20)):
57
+ out[..., dip, :] = 0.5 * (out[..., pip, :] + out[..., tip, :])
58
+ return out
59
+
60
+
61
+ def wrist_frame(w, imcp, lmcp):
62
+ """Right-handed frame (columns = axes) at the wrist, or None when degenerate."""
63
+ x = (imcp + lmcp) * 0.5 - w
64
+ n = np.cross(imcp - w, lmcp - w)
65
+ nx, nn = np.linalg.norm(x), np.linalg.norm(n)
66
+ if nx < 1e-9 or nn < 1e-9:
67
+ return None
68
+ x, n = x / nx, n / nn
69
+ return np.stack([x, np.cross(n, x), n], axis=1)
70
+
71
+
72
+ def palm_size(p17: np.ndarray) -> float:
73
+ """Mean wrist-to-knuckle distance of the four fingers (17-keypoint layout)."""
74
+ return float(np.mean([np.linalg.norm(p17[CHAINS17[f][0]] - p17[WRIST17]) for f in FINGERS4]))
75
+
76
+
77
+ def local_vectors(p17: np.ndarray):
78
+ """``(17, 3)`` keypoints -> ``(len(VECTORS17), 3)`` vectors in the hand's wrist frame,
79
+ or None when the frame is degenerate."""
80
+ R = wrist_frame(p17[WRIST17], p17[INDEX17], p17[LITTLE17])
81
+ if R is None:
82
+ return None
83
+ o, t = zip(*VECTORS17)
84
+ return (p17[list(t)] - p17[list(o)]) @ R
85
+
86
+
87
+ def human17(j21: np.ndarray) -> np.ndarray:
88
+ """``(21, 3)`` human joints -> the 17-keypoint layout."""
89
+ return np.asarray(j21, np.float64)[list(MEDIAPIPE21_TO_17)]
90
+
91
+
92
+ def bone_match(local17: np.ndarray, j21: np.ndarray) -> np.ndarray:
93
+ """Stretch robot finger segments (wrist-frame 17 keypoints) to the human's lengths.
94
+
95
+ Each knuckle is moved along its own direction to the human wrist-to-knuckle distance, then
96
+ the rest of the finger is scaled about the knuckle so knuckle-to-tip matches the human's.
97
+ Directions stay the robot's.
98
+ """
99
+ out = local17.copy()
100
+ for f, (k, distal) in CHAINS17.items():
101
+ hm, ht = CHAINS21[f][0], CHAINS21[f][-1]
102
+ r_palm, h_palm = np.linalg.norm(local17[k]), np.linalg.norm(j21[hm] - j21[0])
103
+ if r_palm > 1e-6 and h_palm > 1e-6:
104
+ out[k] = local17[k] * (h_palm / r_palm)
105
+ r_fin = np.linalg.norm(local17[distal[-1]] - local17[k])
106
+ h_fin = np.linalg.norm(j21[ht] - j21[hm])
107
+ s = h_fin / r_fin if (r_fin > 1e-6 and h_fin > 1e-6) else 1.0
108
+ for i in distal:
109
+ out[i] = out[k] + s * (local17[i] - local17[k])
110
+ return out
111
+
112
+
113
+ def place_on_human(robot17: np.ndarray, j21: np.ndarray, match_bones: bool = True):
114
+ """Move robot keypoints (any frame) onto the human wrist frame; None if degenerate."""
115
+ Rr = wrist_frame(robot17[WRIST17], robot17[INDEX17], robot17[LITTLE17])
116
+ Rh = wrist_frame(j21[0], j21[5], j21[17])
117
+ if Rr is None or Rh is None:
118
+ return None
119
+ local = (robot17 - robot17[WRIST17]) @ Rr
120
+ if match_bones:
121
+ local = bone_match(local, j21)
122
+ return j21[0] + local @ Rh.T
robotryon/embodiment/spec.py ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Embodiment description parsed from YAML (see ``configs/embodiments/gr1.yaml``).
2
+
3
+ Everything that differs between robots lives here as data: the kinematic model file, the
4
+ layout of the recorded joint vector, passive joints coupled to actuated ones, the link that
5
+ stands for each of the 17 hand keypoints, the cameras, the links that make up the robot's
6
+ silhouette, and which hand joints the human-hand retargeting moves. Parsing is pure Python
7
+ so configs can be validated without a physics engine.
8
+
9
+ Strings may contain ``{s}``, which is replaced by each hand's side token (``hands.sides``), so
10
+ a mirror-symmetric robot is described once for both hands.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from dataclasses import dataclass, field
16
+ from pathlib import Path
17
+ from typing import Any
18
+
19
+ import numpy as np
20
+ import yaml
21
+
22
+ from robotryon.skeleton.topology import NUM_KEYPOINTS
23
+
24
+ from .camera import CameraModel
25
+
26
+ HANDS = ("left", "right")
27
+
28
+
29
+ @dataclass
30
+ class CameraMount:
31
+ """Where a camera sits: on a model frame that moves with the robot, or fixed in the base.
32
+
33
+ ``frame`` is ``site:<name>``/``body:<name>`` (or a bare name, resolved site first);
34
+ ``rotation`` maps the frame's axes to the optical frame (``p_opt = R @ p_frame``);
35
+ ``translation`` offsets the lens origin in the mount frame (e.g. a stereo baseline).
36
+ ``extrinsic`` instead gives a fixed 4x4 camera-to-base pose.
37
+ """
38
+ frame: str | None = None
39
+ rotation: np.ndarray = field(default_factory=lambda: np.eye(3))
40
+ translation: np.ndarray = field(default_factory=lambda: np.zeros(3))
41
+ extrinsic: np.ndarray | None = None
42
+
43
+
44
+ @dataclass
45
+ class CameraSpec:
46
+ intrinsics: CameraModel
47
+ mount: CameraMount
48
+ video_key: str | None = None
49
+
50
+
51
+ @dataclass
52
+ class RetargetSpec:
53
+ """Vector retargeting of a human hand onto the robot hand (``robotryon.embodiment.retarget``).
54
+
55
+ ``joints``: per hand, the actuated hand joints the optimization moves; coupled joints follow
56
+ their source through ``coupling`` and the limits come from the robot model.
57
+ ``scaling``: factor on the human vectors, or None for the ratio of the palm sizes.
58
+ ``smoothing``: weight of the squared change from the previous frame's joints.
59
+ ``huber_delta``: Huber threshold (m) of the vector residuals.
60
+ ``limits``: ``{joint: (lo, hi)}`` narrowing the model's joint limits for the retargeting, e.g.
61
+ to keep finger joints out of hyperextension the model allows but the real hand never uses.
62
+ """
63
+ joints: dict[str, list[str]] = field(default_factory=dict)
64
+ limits: dict[str, tuple[float, float]] = field(default_factory=dict)
65
+ scaling: float | None = None
66
+ smoothing: float = 4e-3
67
+ huber_delta: float = 0.02
68
+ bone_match: bool = True
69
+
70
+
71
+ @dataclass
72
+ class EmbodimentSpec:
73
+ name: str
74
+ model_path: str
75
+ joints: list[str | None]
76
+ joint_scale: dict[str, float] # joint -> model value / recorded value
77
+ arm_joints: dict[str, tuple[int, int]] # hand -> [start, stop) in the joint vector
78
+ coupling: list[tuple[str, float, str]] # (joint, ratio, source joint)
79
+ fixed_joints: dict[str, float]
80
+ keypoints: dict[str, list[str]] # hand -> 17 frame names
81
+ keypoint_offsets: dict[str, list] # hand -> 17 offsets (m, in the frame) or None
82
+ wrist_frame: dict[str, str] # hand -> frame giving the wrist pose
83
+ finger_scale: dict[str, float]
84
+ cameras: dict[str, CameraSpec]
85
+ silhouette_bodies: list[str]
86
+ silhouette_exclude: list[str]
87
+ retarget: RetargetSpec = field(default_factory=RetargetSpec)
88
+ raw: dict = field(default_factory=dict)
89
+
90
+ @property
91
+ def hands(self) -> list[str]:
92
+ return [h for h in HANDS if h in self.keypoints]
93
+
94
+ @property
95
+ def views(self) -> list[str]:
96
+ return list(self.cameras)
97
+
98
+
99
+ def _fmt(x: Any, s: str) -> Any:
100
+ return x.replace("{s}", s) if isinstance(x, str) else x
101
+
102
+
103
+ def _per_hand(value, hand: str):
104
+ """``value`` or ``value[hand]`` when it is given per hand."""
105
+ if isinstance(value, dict) and hand in value:
106
+ return value[hand]
107
+ return value
108
+
109
+
110
+ def parse_spec(cfg: dict, base_dir: str | Path | None = None) -> EmbodimentSpec:
111
+ hands_cfg = cfg.get("hands", {})
112
+ sides = hands_cfg.get("sides", {"left": "left", "right": "right"})
113
+
114
+ keypoints, keypoint_offsets, wrist_frame = {}, {}, {}
115
+ for hand, tok in sides.items():
116
+ if hand not in HANDS:
117
+ raise ValueError(f"hands.sides keys must be left/right, got {hand!r}")
118
+ entries = (hands_cfg.get(hand) or {}).get("keypoints") or hands_cfg.get("keypoints")
119
+ if entries is None:
120
+ raise ValueError(f"no keypoints given for the {hand} hand")
121
+ if len(entries) != NUM_KEYPOINTS:
122
+ raise ValueError(f"{hand} hand needs {NUM_KEYPOINTS} keypoints, got {len(entries)}")
123
+ # an entry is a frame name, or {frame: name, offset: [x, y, z]} for a point fixed in it
124
+ names = [_fmt(e["frame"] if isinstance(e, dict) else e, tok) for e in entries]
125
+ keypoint_offsets[hand] = [np.asarray(_per_hand(e["offset"], hand), np.float64).reshape(3)
126
+ if isinstance(e, dict) and e.get("offset") is not None else None
127
+ for e in entries]
128
+ keypoints[hand] = names
129
+ wf = (hands_cfg.get(hand) or {}).get("wrist_frame") or hands_cfg.get("wrist_frame")
130
+ wrist_frame[hand] = _fmt(wf, tok) if wf else names[0]
131
+
132
+ coupling = []
133
+ for c in cfg.get("coupling", []) or []:
134
+ templated = "{s}" in c["joint"] or "{s}" in c["source"]
135
+ for tok in (sides.values() if templated else [""]):
136
+ coupling.append((_fmt(c["joint"], tok), float(c["ratio"]), _fmt(c["source"], tok)))
137
+
138
+ cameras = {}
139
+ for view, c in (cfg.get("cameras") or {}).items():
140
+ m = c.get("mount", {}) or {}
141
+ ext = c.get("extrinsic")
142
+ mount = CameraMount(
143
+ frame=m.get("frame"),
144
+ rotation=np.asarray(m.get("rotation", np.eye(3)), np.float64).reshape(3, 3),
145
+ translation=np.asarray(m.get("translation", [0, 0, 0]), np.float64).reshape(3),
146
+ extrinsic=None if ext is None else np.asarray(ext, np.float64).reshape(4, 4))
147
+ if mount.frame is None and mount.extrinsic is None:
148
+ raise ValueError(f"camera {view!r} needs mount.frame or extrinsic")
149
+ cameras[view] = CameraSpec(CameraModel.from_dict(c["intrinsics"]), mount,
150
+ c.get("video_key"))
151
+
152
+ rt = cfg.get("retarget", {}) or {}
153
+ if "drivers" in rt:
154
+ raise ValueError("retarget.drivers (percentile retargeting) was replaced by vector "
155
+ "retargeting: list the hand joints under retarget.joints instead")
156
+ rt_joints = {hand: [_fmt(j, tok) for j in (_per_hand(rt.get("joints") or [], hand) or [])]
157
+ for hand, tok in sides.items()}
158
+ limits = {}
159
+ for j, r in (rt.get("limits") or {}).items():
160
+ for hand, tok in sides.items():
161
+ rr = _per_hand(r, hand)
162
+ limits[_fmt(j, tok)] = (float(rr[0]), float(rr[1]))
163
+ scaling = rt.get("scaling", "auto")
164
+ retarget = RetargetSpec(
165
+ joints=rt_joints,
166
+ limits=limits,
167
+ scaling=None if scaling in (None, "auto") else float(scaling),
168
+ smoothing=float(rt.get("smoothing", 4e-3)),
169
+ huber_delta=float(rt.get("huber_delta", 0.02)),
170
+ bone_match=bool(rt.get("bone_match", True)))
171
+
172
+ model_path = str(cfg["model_path"])
173
+ if base_dir is not None and not Path(model_path).is_absolute():
174
+ model_path = str(Path(base_dir) / model_path)
175
+ sil = cfg.get("silhouette", {}) or {}
176
+ toks = list(sides.values())
177
+ expand = lambda xs: sorted({_fmt(x, t) for x in xs or [] for t in toks}) # noqa: E731
178
+ return EmbodimentSpec(
179
+ name=str(cfg.get("name", "robot")),
180
+ model_path=model_path,
181
+ joints=[j["name"] if isinstance(j, dict) else j for j in cfg.get("joints", [])],
182
+ joint_scale={j["name"]: float(j["scale"]) for j in cfg.get("joints", [])
183
+ if isinstance(j, dict) and "scale" in j},
184
+ arm_joints={h: (int(r[0]), int(r[1])) for h, r in (cfg.get("arm_joints") or {}).items()},
185
+ coupling=coupling,
186
+ fixed_joints={k: float(v) for k, v in (cfg.get("fixed_joints") or {}).items()},
187
+ keypoints=keypoints,
188
+ keypoint_offsets=keypoint_offsets,
189
+ wrist_frame=wrist_frame,
190
+ finger_scale={k: float(v) for k, v in (hands_cfg.get("finger_scale") or {}).items()},
191
+ cameras=cameras,
192
+ silhouette_bodies=expand(sil.get("bodies")),
193
+ silhouette_exclude=expand(sil.get("exclude")),
194
+ retarget=retarget,
195
+ raw=cfg,
196
+ )
197
+
198
+
199
+ def load_spec(path: str | Path) -> EmbodimentSpec:
200
+ path = Path(path)
201
+ return parse_spec(yaml.safe_load(path.read_text()), base_dir=path.parent)
robotryon/idm/__init__.py ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ """Inverse dynamics model: training data, model, action labelling and wrist correction.
2
+
3
+ Torch-dependent modules (``model``, ``data``, ``labeling``) are imported explicitly by the
4
+ scripts; ``wrist_correction`` only needs numpy/scipy.
5
+ """
robotryon/idm/data.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # Adapted from GR00T-Dreams (Apache-2.0): gr00t/data/dataset.py (LeRobot v2.1 reader),
17
+ # gr00t/data/transform/{video,state_action,mirror}.py, gr00t/model/transforms_idm.py and the
18
+ # multi-embodiment mixture. Rewritten as one compact pipeline; the eval-mode image path and
19
+ # the q99 normalisation are numerically identical to the original.
20
+ """IDM training data: LeRobot v2.1 datasets of robot videos with joint actions.
21
+
22
+ Each embodiment is declared in the config (no robot list in code)::
23
+
24
+ name: openarm_rh56f1 # also the key of its statistics in the checkpoint metadata
25
+ embodiment_id: 29 # row of the per-embodiment action encoder/decoder
26
+ root: /path/to/lerobot_dataset
27
+ views: [camera_ego_left, camera_ego_right] # LeRobot video keys; one key = mono
28
+ view_ids: [0, 1, 2, 3] # view-embedding id of [t0 v0, t0 v1, t1 v0, t1 v1]
29
+ action_groups: [neck_joints, left_arm_joints, ...] # modality.json groups, raw order
30
+ hand_groups: [left_hand_joints, right_hand_joints] # smoothed with the short SG window
31
+ norm: q99 # or q99_const (near-constant joints -> 0)
32
+ video_size: [512, 288] # dataset frame size; labelled videos are resized to it
33
+ mirror: {prob, view_swap, swap, sign} # optional left/right reflection augmentation
34
+
35
+ A sample is frames (t, t+K) of every view and the chunk a_{t:t+K}, normalised to [-1, 1] with
36
+ the dataset's q01/q99 and zero-padded to the model's action_dim.
37
+ """
38
+
39
+ from __future__ import annotations
40
+
41
+ import json
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+ import torch
46
+ from torch.utils.data import Dataset
47
+
48
+ IMAGE_SIZE = 224
49
+ _MEAN = np.array([0.485, 0.456, 0.406], dtype=np.float32)
50
+ _STD = np.array([0.229, 0.224, 0.225], dtype=np.float32)
51
+ CONST_EPS = 1e-3
52
+
53
+
54
+ # ----------------------------------------------------------------------------- normalisation
55
+ def normalize(x: torch.Tensor, q01: torch.Tensor, q99: torch.Tensor, mode: str) -> torch.Tensor:
56
+ q01, q99 = q01.to(x.dtype), q99.to(x.dtype)
57
+ if mode == "q99": # constant dims (q01 == q99) pass through
58
+ live = q01 != q99
59
+ out = x.clone()
60
+ elif mode == "q99_const": # near-constant dims are mapped to 0
61
+ live = (q99 - q01) > CONST_EPS
62
+ out = torch.zeros_like(x)
63
+ else:
64
+ raise ValueError(f"unknown norm mode {mode!r}")
65
+ out[..., live] = 2 * (x[..., live] - q01[live]) / (q99[live] - q01[live]) - 1
66
+ return out.clamp(-1, 1)
67
+
68
+
69
+ def unnormalize(x: torch.Tensor, q01: torch.Tensor, q99: torch.Tensor, mode: str) -> torch.Tensor:
70
+ q01, q99 = q01.to(x.dtype), q99.to(x.dtype)
71
+ out = (x + 1) / 2 * (q99 - q01) + q01
72
+ if mode == "q99_const":
73
+ out = torch.where((q99 - q01) > CONST_EPS, out, ((q01 + q99) / 2).expand_as(x))
74
+ return out
75
+
76
+
77
+ def group_stats(stats: dict, groups: list[str]) -> tuple[torch.Tensor, torch.Tensor]:
78
+ """Concatenate the per-group q01/q99 (metadata["statistics"]["action"]) in group order."""
79
+ q01 = np.concatenate([stats[g]["q01"] for g in groups])
80
+ q99 = np.concatenate([stats[g]["q99"] for g in groups])
81
+ return torch.tensor(q01.tolist()), torch.tensor(q99.tolist())
82
+
83
+
84
+ # ----------------------------------------------------------------------------- images
85
+ def preprocess_frames(frames: np.ndarray, train: bool = False, fov_range=None,
86
+ color_jitter=None) -> np.ndarray:
87
+ """(N, H, W, 3) uint8 -> (N, 3, 224, 224) float32, ImageNet-normalised.
88
+
89
+ All N frames (every view and both timesteps of one sample) share one random draw, so the
90
+ stereo geometry and the temporal pair stay consistent. Train-time augmentation: field of
91
+ view (random zoom-in crop or zoom-out black padding at a random offset) and colour jitter.
92
+ """
93
+ import torchvision.transforms.v2 as T
94
+ import torchvision.transforms.v2.functional as TF
95
+
96
+ x = torch.from_numpy(np.ascontiguousarray(frames)).to(torch.float32).div(255.0).permute(0, 3, 1, 2)
97
+ if train and fov_range is not None:
98
+ x = _random_fov(x, *fov_range)
99
+ x = TF.resize(x, [IMAGE_SIZE, IMAGE_SIZE], interpolation=T.InterpolationMode.BILINEAR,
100
+ antialias=True)
101
+ if train and color_jitter is not None:
102
+ x = T.ColorJitter(*color_jitter)(x)
103
+ u8 = (x.permute(0, 2, 3, 1) * 255).to(torch.uint8).numpy()
104
+ # Channels-last memory (a transposed view, no copy): cuDNN picks its convolution kernel by
105
+ # layout, and this is the layout the IDM was trained and evaluated with.
106
+ return ((u8.astype(np.float32) / 255.0 - _MEAN) / _STD).transpose(0, 3, 1, 2)
107
+
108
+
109
+ def _random_fov(x: torch.Tensor, lo: float, hi: float) -> torch.Tensor:
110
+ import torchvision.transforms.v2.functional as TF
111
+
112
+ H, W = x.shape[-2:]
113
+ s = float(torch.empty(1).uniform_(lo, hi))
114
+ if s <= 1.0:
115
+ h, w = max(1, round(H * s)), max(1, round(W * s))
116
+ top, left = int(torch.randint(0, H - h + 1, (1,))), int(torch.randint(0, W - w + 1, (1,)))
117
+ return x[..., top:top + h, left:left + w]
118
+ h, w = round(H * s), round(W * s)
119
+ top, left = int(torch.randint(0, h - H + 1, (1,))), int(torch.randint(0, w - W + 1, (1,)))
120
+ return TF.pad(x, [left, top, w - W - left, h - H - top], fill=0.0)
121
+
122
+
123
+ def model_inputs(images: np.ndarray, spec: dict, horizon: int, action_dim: int) -> dict:
124
+ """The per-sample fields every batch needs besides the action target."""
125
+ n_native = native_dim(spec)
126
+ mask = np.zeros((horizon, action_dim), dtype=bool)
127
+ mask[:, :n_native] = True
128
+ view_ids = list(spec["view_ids"])
129
+ assert len(view_ids) == len(images), f"view_ids {view_ids} for {len(images)} images"
130
+ return dict(images=images, view_ids=np.asarray(view_ids, np.int64), n_images=len(images),
131
+ embodiment_id=int(spec["embodiment_id"]), actions_mask=mask)
132
+
133
+
134
+ def collate(samples: list[dict]) -> dict:
135
+ out = {}
136
+ for k in samples[0]:
137
+ vals = [s[k] for s in samples]
138
+ cat = k in ("images", "view_ids")
139
+ out[k] = torch.from_numpy(np.concatenate(vals) if cat else np.stack(vals)) \
140
+ if isinstance(vals[0], np.ndarray) else torch.tensor(vals)
141
+ return out
142
+
143
+
144
+ # ----------------------------------------------------------------------------- LeRobot reader
145
+ class LeRobotMeta:
146
+ """meta/{info,modality,episodes,stats} of a LeRobot v2.1 dataset, reduced to what the IDM
147
+ needs: per-group column slices of the action, video paths and episode lengths."""
148
+
149
+ def __init__(self, root: str | Path):
150
+ self.root = Path(root)
151
+ m = self.root / "meta"
152
+ self.info = json.loads((m / "info.json").read_text())
153
+ self.modality = json.loads((m / "modality.json").read_text())
154
+ self.episodes = [json.loads(l) for l in (m / "episodes.jsonl").read_text().splitlines() if l.strip()]
155
+ self.stats = json.loads((m / "stats.json").read_text())
156
+
157
+ def action_slice(self, group: str) -> tuple[str, int, int]:
158
+ g = self.modality["action"][group]
159
+ return g.get("original_key") or "action", g["start"], g["end"]
160
+
161
+ def action_statistics(self, groups: list[str]) -> dict:
162
+ out = {}
163
+ for g in groups:
164
+ col, a, b = self.action_slice(g)
165
+ out[g] = {k: list(np.asarray(v)[a:b]) for k, v in self.stats[col].items()}
166
+ return out
167
+
168
+ def parquet(self, ep: int) -> Path:
169
+ return self.root / self.info["data_path"].format(
170
+ episode_chunk=ep // self.info["chunks_size"], episode_index=ep)
171
+
172
+ def video(self, ep: int, view: str) -> Path:
173
+ key = self.modality["video"][view].get("original_key") or view
174
+ return self.root / self.info["video_path"].format(
175
+ episode_chunk=ep // self.info["chunks_size"], episode_index=ep, video_key=key)
176
+
177
+
178
+ def native_dim(spec: dict) -> int:
179
+ return int(spec["action_dim"])
180
+
181
+
182
+ def frames_at(path: Path, timestamps: np.ndarray) -> np.ndarray:
183
+ """Frames nearest to the given timestamps (seconds), (N, H, W, 3) uint8 RGB."""
184
+ import decord
185
+
186
+ vr = decord.VideoReader(str(path))
187
+ ts = vr.get_frame_timestamp(range(len(vr)))[:, :1]
188
+ return vr.get_batch(np.abs(ts - timestamps).argmin(axis=0)).asnumpy()
189
+
190
+
191
+ class Mirror:
192
+ """Left/right reflection: flip the frames, exchange the two eyes, swap left/right joint
193
+ groups (with a per-joint sign) and negate the joints listed in ``sign``. Declared per
194
+ embodiment because the sign map depends on the kinematics."""
195
+
196
+ def __init__(self, cfg: dict, groups: list[str], views: list[str]):
197
+ self.prob = float(cfg.get("prob", 0.0))
198
+ self.view_perm = list(range(len(views)))
199
+ for a, b in cfg.get("view_swap", []):
200
+ ia, ib = views.index(a), views.index(b)
201
+ self.view_perm[ia], self.view_perm[ib] = ib, ia
202
+ self.swaps = [(groups.index(a), groups.index(b), s) for a, b, s in cfg.get("swap", [])]
203
+ self.signs = {groups.index(g): np.asarray(s) for g, s in (cfg.get("sign") or {}).items()}
204
+
205
+ def __call__(self, frames: list[np.ndarray], parts: list[np.ndarray]):
206
+ if self.prob <= 0 or float(torch.rand(())) >= self.prob:
207
+ return frames, parts
208
+ frames = [np.ascontiguousarray(frames[i][..., ::-1, :]) for i in self.view_perm]
209
+ parts = list(parts)
210
+ for ia, ib, s in self.swaps:
211
+ s = 1.0 if s is None else np.asarray(s)
212
+ parts[ia], parts[ib] = parts[ib] * s, parts[ia] * s
213
+ for i, s in self.signs.items():
214
+ parts[i] = parts[i] * s
215
+ return frames, parts
216
+
217
+
218
+ class LeRobotIDMDataset(Dataset):
219
+ """Every frame t of every episode is a sample: frames (t, t+K) of all views (clamped to the
220
+ episode), and actions a_t .. a_{t+H-1} (the last action repeats past the end)."""
221
+
222
+ def __init__(self, spec: dict, model_cfg: dict, data_cfg: dict, train: bool = True):
223
+ self.spec, self.train = spec, train
224
+ self.meta = LeRobotMeta(spec["root"])
225
+ self.groups = list(spec["action_groups"])
226
+ self.H, self.A = int(model_cfg["action_horizon"]), int(model_cfg["action_dim"])
227
+ self.K = int(data_cfg["frame_gap"])
228
+ eps = self.meta.episodes[: data_cfg.get("max_episodes") or None]
229
+ self.steps = [(e["episode_index"], t, e["length"]) for e in eps for t in range(e["length"])]
230
+ self.statistics = self.meta.action_statistics(self.groups)
231
+ self.q01, self.q99 = group_stats(self.statistics, self.groups)
232
+ widths = [self.meta.action_slice(g)[2] - self.meta.action_slice(g)[1] for g in self.groups]
233
+ assert sum(widths) == native_dim(spec) <= self.A, (spec["name"], widths, self.A)
234
+ self.mirror = Mirror(spec.get("mirror") or {}, self.groups, list(spec["views"]))
235
+ self.fov = spec.get("fov_range", data_cfg.get("fov_range"))
236
+ self.jitter = data_cfg.get("color_jitter")
237
+ self._cache = (None, None)
238
+
239
+ def __len__(self):
240
+ return len(self.steps)
241
+
242
+ def metadata(self) -> dict:
243
+ return {"statistics": {"action": self.statistics}, "spec": dict(self.spec)}
244
+
245
+ def _episode(self, ep: int):
246
+ if self._cache[0] != ep:
247
+ import pandas as pd
248
+ self._cache = (ep, pd.read_parquet(self.meta.parquet(ep)))
249
+ return self._cache[1]
250
+
251
+ def __getitem__(self, i: int) -> dict:
252
+ ep, t, L = self.steps[i]
253
+ df = self._episode(ep)
254
+ obs = np.clip(np.array([t, t + self.K]), 0, L - 1)
255
+ ts = df["timestamp"].to_numpy()[obs]
256
+ frames = [frames_at(self.meta.video(ep, v), ts) for v in self.spec["views"]]
257
+ rows = np.minimum(np.arange(t, t + self.H), L - 1)
258
+ parts = []
259
+ for g in self.groups:
260
+ col, a, b = self.meta.action_slice(g)
261
+ parts.append(np.stack(df[col].to_numpy()[rows])[:, a:b].astype(np.float64))
262
+ if self.train:
263
+ frames, parts = self.mirror(frames, parts)
264
+ # (v, t) -> images ordered [t0 v0, t0 v1, t1 v0, t1 v1]
265
+ stacked = np.stack(frames, 1).reshape(-1, *frames[0].shape[1:])
266
+ images = preprocess_frames(stacked, self.train, self.fov, self.jitter)
267
+ act = normalize(torch.from_numpy(np.concatenate(parts, 1)), self.q01, self.q99,
268
+ self.spec.get("norm", "q99")).numpy()
269
+ sample = model_inputs(images, self.spec, self.H, self.A)
270
+ sample["actions"] = np.pad(act, ((0, 0), (0, self.A - act.shape[1]))).astype(np.float32)
271
+ return sample
272
+
273
+
274
+ class MixtureDataset(Dataset):
275
+ """Weighted mixture of embodiments. Each epoch, embodiment k fills round(w_k * N) of the
276
+ N = sum(len) slots with a fresh random permutation of its samples (repeated if up-weighted)."""
277
+
278
+ def __init__(self, datasets: list[LeRobotIDMDataset], weights: list[float], seed: int = 42):
279
+ self.datasets = datasets
280
+ w = np.asarray(weights, np.float64)
281
+ self.weights = w / w.sum()
282
+ self.seed, self.N = seed, sum(len(d) for d in datasets)
283
+ self.epoch, self.table = None, None
284
+ self.set_epoch(0)
285
+
286
+ def set_epoch(self, epoch: int) -> None:
287
+ if epoch == self.epoch:
288
+ return
289
+ self.epoch = epoch
290
+ rng = np.random.default_rng(self.seed + epoch)
291
+ raw = self.weights * self.N
292
+ slots = np.floor(raw).astype(int)
293
+ for i in np.argsort(-(raw - slots))[: self.N - slots.sum()]:
294
+ slots[i] += 1
295
+ rows = []
296
+ for k, (ds, n_slots) in enumerate(zip(self.datasets, slots)):
297
+ reps, rem = divmod(int(n_slots), len(ds))
298
+ local = np.concatenate([rng.permutation(len(ds)) for _ in range(reps)]
299
+ + [rng.permutation(len(ds))[:rem]])
300
+ rows.append(np.stack([np.full(n_slots, k), local], 1))
301
+ self.table = np.concatenate(rows).astype(np.int64)
302
+
303
+ def __len__(self):
304
+ return self.N
305
+
306
+ def __getitem__(self, i):
307
+ k, j = self.table[i]
308
+ return self.datasets[k][j]
309
+
310
+
311
+ def embodiment_specs(data_cfg: dict) -> dict[str, dict]:
312
+ """Embodiments declared in the config, keyed by name, with ``data.roots`` overrides applied."""
313
+ roots = data_cfg.get("roots") or {}
314
+ specs = {}
315
+ for e in data_cfg["embodiments"]:
316
+ spec = dict(e)
317
+ spec["root"] = roots.get(spec["name"], spec.get("root"))
318
+ specs[spec["name"]] = spec
319
+ return specs
robotryon/idm/dit.py ADDED
@@ -0,0 +1,143 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # Adapted from GR00T-Dreams (Apache-2.0), gr00t/model/action_head/cross_attention_dit.py.
17
+ # Changes: plain nn.Module instead of diffusers ModelMixin/ConfigMixin, unused options removed.
18
+ # Parameter names are unchanged so GR00T-Dreams IDM checkpoints load as-is.
19
+ """Diffusion transformer (DiT) of the flow-matching action head, and the self-attention
20
+ transformer that mixes the visual tokens before the DiT cross-attends to them."""
21
+
22
+ from __future__ import annotations
23
+
24
+ from typing import Optional
25
+
26
+ import torch
27
+ import torch.nn.functional as F
28
+ from diffusers.models.attention import Attention, FeedForward
29
+ from diffusers.models.embeddings import TimestepEmbedding, Timesteps
30
+ from torch import nn
31
+
32
+
33
+ class TimestepEncoder(nn.Module):
34
+ def __init__(self, embedding_dim: int):
35
+ super().__init__()
36
+ self.time_proj = Timesteps(num_channels=256, flip_sin_to_cos=True, downscale_freq_shift=1)
37
+ self.timestep_embedder = TimestepEmbedding(in_channels=256, time_embed_dim=embedding_dim)
38
+
39
+ def forward(self, timesteps):
40
+ dtype = next(self.parameters()).dtype
41
+ return self.timestep_embedder(self.time_proj(timesteps).to(dtype))
42
+
43
+
44
+ class AdaLayerNorm(nn.Module):
45
+ def __init__(self, embedding_dim: int, norm_eps: float = 1e-5):
46
+ super().__init__()
47
+ self.silu = nn.SiLU()
48
+ self.linear = nn.Linear(embedding_dim, embedding_dim * 2)
49
+ self.norm = nn.LayerNorm(embedding_dim, norm_eps, False)
50
+
51
+ def forward(self, x, temb):
52
+ scale, shift = self.linear(self.silu(temb)).chunk(2, dim=1)
53
+ return self.norm(x) * (1 + scale[:, None]) + shift[:, None]
54
+
55
+
56
+ class BasicTransformerBlock(nn.Module):
57
+ """Attention (self, or cross when encoder states are given) + feed-forward, pre-norm."""
58
+
59
+ def __init__(self, dim: int, num_attention_heads: int, attention_head_dim: int,
60
+ dropout: float = 0.0, activation_fn: str = "geglu", attention_bias: bool = False,
61
+ upcast_attention: bool = False, norm_elementwise_affine: bool = True,
62
+ norm_type: str = "layer_norm", norm_eps: float = 1e-5, final_dropout: bool = False):
63
+ super().__init__()
64
+ self.norm_type = norm_type
65
+ if norm_type == "ada_norm":
66
+ self.norm1 = AdaLayerNorm(dim)
67
+ else:
68
+ self.norm1 = nn.LayerNorm(dim, elementwise_affine=norm_elementwise_affine, eps=norm_eps)
69
+ self.attn1 = Attention(query_dim=dim, heads=num_attention_heads, dim_head=attention_head_dim,
70
+ dropout=dropout, bias=attention_bias, cross_attention_dim=None,
71
+ upcast_attention=upcast_attention, out_bias=True)
72
+ self.norm3 = nn.LayerNorm(dim, norm_eps, norm_elementwise_affine)
73
+ self.ff = FeedForward(dim, dropout=dropout, activation_fn=activation_fn,
74
+ final_dropout=final_dropout, inner_dim=None, bias=True)
75
+ self.final_dropout = nn.Dropout(dropout) if final_dropout else None
76
+
77
+ def forward(self, hidden_states, encoder_hidden_states=None, temb=None):
78
+ if self.norm_type == "ada_norm":
79
+ h = self.norm1(hidden_states, temb)
80
+ else:
81
+ h = self.norm1(hidden_states)
82
+ attn = self.attn1(h, encoder_hidden_states=encoder_hidden_states, attention_mask=None)
83
+ if self.final_dropout:
84
+ attn = self.final_dropout(attn)
85
+ hidden_states = attn + hidden_states
86
+ return self.ff(self.norm3(hidden_states)) + hidden_states
87
+
88
+
89
+ class DiT(nn.Module):
90
+ """Action-token transformer conditioned on the flow time (AdaLN) and on the visual tokens.
91
+ With ``interleave_self_attention`` the odd blocks are self-attention only."""
92
+
93
+ def __init__(self, num_attention_heads: int = 16, attention_head_dim: int = 64,
94
+ output_dim: int = 1024, num_layers: int = 8, dropout: float = 0.2,
95
+ attention_bias: bool = True, activation_fn: str = "gelu-approximate",
96
+ upcast_attention: bool = False, norm_type: str = "ada_norm",
97
+ norm_elementwise_affine: bool = False, norm_eps: float = 1e-5,
98
+ final_dropout: bool = True, interleave_self_attention: bool = True):
99
+ super().__init__()
100
+ self.inner_dim = num_attention_heads * attention_head_dim
101
+ self.interleave_self_attention = interleave_self_attention
102
+ self.timestep_encoder = TimestepEncoder(self.inner_dim)
103
+ self.transformer_blocks = nn.ModuleList([
104
+ BasicTransformerBlock(self.inner_dim, num_attention_heads, attention_head_dim,
105
+ dropout=dropout, activation_fn=activation_fn,
106
+ attention_bias=attention_bias, upcast_attention=upcast_attention,
107
+ norm_type=norm_type, norm_elementwise_affine=norm_elementwise_affine,
108
+ norm_eps=norm_eps, final_dropout=final_dropout)
109
+ for _ in range(num_layers)])
110
+ self.norm_out = nn.LayerNorm(self.inner_dim, elementwise_affine=False, eps=1e-6)
111
+ self.proj_out_1 = nn.Linear(self.inner_dim, 2 * self.inner_dim)
112
+ self.proj_out_2 = nn.Linear(self.inner_dim, output_dim)
113
+
114
+ def forward(self, hidden_states, encoder_hidden_states, timestep):
115
+ temb = self.timestep_encoder(timestep)
116
+ hidden_states = hidden_states.contiguous()
117
+ encoder_hidden_states = encoder_hidden_states.contiguous()
118
+ for idx, block in enumerate(self.transformer_blocks):
119
+ cross = not (idx % 2 == 1 and self.interleave_self_attention)
120
+ hidden_states = block(hidden_states, encoder_hidden_states if cross else None, temb)
121
+ shift, scale = self.proj_out_1(F.silu(temb)).chunk(2, dim=1)
122
+ hidden_states = self.norm_out(hidden_states) * (1 + scale[:, None]) + shift[:, None]
123
+ return self.proj_out_2(hidden_states)
124
+
125
+
126
+ class SelfAttentionTransformer(nn.Module):
127
+ def __init__(self, num_attention_heads: int = 16, attention_head_dim: int = 64,
128
+ num_layers: int = 4, dropout: float = 0.2, attention_bias: bool = True,
129
+ activation_fn: str = "gelu-approximate", upcast_attention: bool = False,
130
+ final_dropout: bool = True):
131
+ super().__init__()
132
+ inner = num_attention_heads * attention_head_dim
133
+ self.transformer_blocks = nn.ModuleList([
134
+ BasicTransformerBlock(inner, num_attention_heads, attention_head_dim, dropout=dropout,
135
+ activation_fn=activation_fn, attention_bias=attention_bias,
136
+ upcast_attention=upcast_attention, final_dropout=final_dropout)
137
+ for _ in range(num_layers)])
138
+
139
+ def forward(self, hidden_states: torch.Tensor, temb: Optional[torch.Tensor] = None):
140
+ hidden_states = hidden_states.contiguous()
141
+ for block in self.transformer_blocks:
142
+ hidden_states = block(hidden_states)
143
+ return hidden_states
robotryon/idm/labeling.py ADDED
@@ -0,0 +1,99 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Action labels for a generated robot video (paper Sec. 3.4, App. B.2.6).
2
+
3
+ 1. Sliding window, stride 1: at every frame t the IDM reads frames (t, t+K) (clamped to the
4
+ clip) and predicts the chunk a_t .. a_{t+H-1}.
5
+ 2. Chunk averaging: frame u appears in the H chunks that start at u-H+1 .. u; its label is the
6
+ mean of those predictions (fewer at the clip ends).
7
+ 3. Savitzky-Golay smoothing (polynomial order 2) per joint group: a long window on the arm-like
8
+ groups, a short one on the hand groups so that grasp onsets stay sharp.
9
+ 4. Optional wrist correction (``wrist_correction.py``).
10
+ 5. Proprioceptive state from the actions: q_t = a_{t-d} (the robot reaches a commanded pose
11
+ d frames later); the first d states repeat a_0.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import numpy as np
17
+ import torch
18
+ from scipy.signal import savgol_filter
19
+
20
+ from .data import collate, model_inputs, preprocess_frames, unnormalize
21
+
22
+
23
+ class ChunkAccumulator:
24
+ """Average every chunk prediction that covers a frame."""
25
+
26
+ def __init__(self, length: int, action_dim: int, horizon: int):
27
+ self.L, self.H = int(length), int(horizon)
28
+ self._sum = np.zeros((self.L, action_dim), dtype=np.float64)
29
+ self._cnt = np.zeros((self.L, 1), dtype=np.float64)
30
+
31
+ def add(self, start: int, chunk: np.ndarray) -> None:
32
+ n = min(self.H, len(chunk), self.L - start)
33
+ if n > 0:
34
+ self._sum[start:start + n] += chunk[:n]
35
+ self._cnt[start:start + n] += 1.0
36
+
37
+ def result(self) -> np.ndarray:
38
+ if (self._cnt == 0).any():
39
+ raise RuntimeError("some frames are covered by no chunk")
40
+ return (self._sum / self._cnt).astype(np.float32)
41
+
42
+
43
+ def smooth_actions(actions: np.ndarray, groups: dict[str, slice], hand_groups, window: int,
44
+ hand_window: int, order: int = 2) -> np.ndarray:
45
+ """SG filter per group along time; hand groups use ``hand_window``. A window longer than the
46
+ clip (or not above the order) leaves the group unchanged."""
47
+ out = np.array(actions, dtype=np.float32, copy=True)
48
+ for name, sl in groups.items():
49
+ w = int(hand_window if name in hand_groups else window) | 1
50
+ if order < w <= len(out):
51
+ out[:, sl] = savgol_filter(out[:, sl], w, order, axis=0, mode="nearest")
52
+ return out
53
+
54
+
55
+ def state_from_action(actions: np.ndarray, delay: int) -> np.ndarray:
56
+ """q_t = a_{t-delay}; q_t = a_0 for t < delay."""
57
+ a = np.asarray(actions)
58
+ d = min(int(delay), len(a))
59
+ return np.concatenate([np.repeat(a[:1], d, axis=0), a[: len(a) - d]], axis=0)
60
+
61
+
62
+ def group_slices(spec: dict, widths: dict[str, int]) -> dict[str, slice]:
63
+ out, s = {}, 0
64
+ for g in spec["action_groups"]:
65
+ out[g] = slice(s, s + widths[g])
66
+ s += widths[g]
67
+ return out
68
+
69
+
70
+ @torch.no_grad()
71
+ def predict_actions(model, views: list[np.ndarray], spec: dict, stats: dict, frame_gap: int,
72
+ batch_size: int, generator: torch.Generator | None = None) -> np.ndarray:
73
+ """Chunk-averaged raw actions (T, native_dim) for one clip.
74
+
75
+ ``views``: one (T, H, W, 3) uint8 RGB array per view in ``spec["views"]`` order, already at
76
+ the embodiment's ``video_size``. ``stats``: per-group action statistics of this embodiment.
77
+ """
78
+ H, A = model.cfg["action_horizon"], model.cfg["action_dim"]
79
+ groups = list(spec["action_groups"])
80
+ q01 = torch.tensor(np.concatenate([stats[g]["q01"] for g in groups]).tolist())
81
+ q99 = torch.tensor(np.concatenate([stats[g]["q99"] for g in groups]).tolist())
82
+ n_native = len(q01)
83
+ T = min(len(v) for v in views)
84
+ device = next(model.parameters()).device
85
+ acc = ChunkAccumulator(T, n_native, H)
86
+
87
+ def sample(t):
88
+ obs = np.clip(np.array([t, t + frame_gap]), 0, T - 1)
89
+ frames = np.stack([v[obs] for v in views], 1).reshape(-1, *views[0].shape[1:])
90
+ return model_inputs(preprocess_frames(frames), spec, H, A)
91
+
92
+ for s in range(0, T, batch_size):
93
+ batch = collate([sample(t) for t in range(s, min(s + batch_size, T))])
94
+ batch = {k: v.to(device) for k, v in batch.items()}
95
+ pred = model.get_action(batch, generator=generator).cpu()[..., :n_native]
96
+ raw = unnormalize(pred, q01, q99, spec.get("norm", "q99")).numpy()
97
+ for b, chunk in enumerate(raw):
98
+ acc.add(s + b, chunk)
99
+ return acc.result()
robotryon/idm/model.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2
+ # SPDX-License-Identifier: Apache-2.0
3
+ #
4
+ # Licensed under the Apache License, Version 2.0 (the "License");
5
+ # you may not use this file except in compliance with the License.
6
+ # You may obtain a copy of the License at
7
+ #
8
+ # http://www.apache.org/licenses/LICENSE-2.0
9
+ #
10
+ # Unless required by applicable law or agreed to in writing, software
11
+ # distributed under the License is distributed on an "AS IS" BASIS,
12
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ # See the License for the specific language governing permissions and
14
+ # limitations under the License.
15
+ #
16
+ # Adapted from GR00T-Dreams (Apache-2.0): gr00t/model/idm.py,
17
+ # gr00t/model/action_head/flow_matching_action_head_idm.py, multimodal_projector.py.
18
+ # Changes: Depth Anything V2 vision tower instead of SigLIP, no hydra, plain safetensors I/O,
19
+ # image tokens placed from a per-sample image count instead of token-id arrays.
20
+ """Inverse dynamics model h_psi (paper Sec. 3.4, App. B.2.6).
21
+
22
+ Given two frames o_t and o_{t+K} (K = 20, 1 s at 20 fps; one or two camera views each), the IDM
23
+ predicts the action chunk a_{t:t+K} by flow matching:
24
+
25
+ images --DINOv2 trunk of Depth Anything V2-L--> 16x16 patch tokens (CLS dropped)
26
+ --linear + 2x(2x2 pool MLP)--> 16 tokens per image, + view embedding
27
+ tokens --self-attention transformer--> visual context (left zero-padded to 112 tokens)
28
+ noisy chunk a^tau --per-embodiment action encoder--> DiT (AdaLN on tau, cross-attention to
29
+ the context) --per-embodiment decoder--> velocity v; loss ||v - (a - eps)||^2
30
+
31
+ One model is shared by all embodiments. Actions are zero-padded to ``action_dim`` (the widest
32
+ robot) and the padded dimensions are masked out of the input and the loss; the action encoder
33
+ and decoder have separate weights per embodiment id. At inference the chunk is integrated
34
+ from Gaussian noise with ``num_inference_timesteps`` Euler steps.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import json
40
+ import re
41
+ from pathlib import Path
42
+
43
+ import torch
44
+ import torch.nn.functional as F
45
+ from torch import nn
46
+ from torch.distributions import Beta
47
+
48
+ from .dit import DiT, SelfAttentionTransformer
49
+
50
+ DEFAULT_MODEL_CFG = dict(
51
+ vision_tower="depth-anything/Depth-Anything-V2-Large-hf",
52
+ vision_hidden_size=1024, hidden_size=1024,
53
+ action_dim=66, action_horizon=20,
54
+ max_num_embodiments=32, max_num_views=6, max_seq_len=1024,
55
+ max_vl_tokens=112, tokens_per_image=16,
56
+ frozen_vision_layers=[11],
57
+ num_inference_timesteps=16, num_timestep_buckets=1000,
58
+ noise_beta_alpha=1.5, noise_beta_beta=1.0, noise_s=0.999,
59
+ dit=dict(num_layers=8, num_attention_heads=16, attention_head_dim=64, dropout=0.2,
60
+ output_dim=1024, interleave_self_attention=True),
61
+ vl_self_attention=dict(num_layers=4, num_attention_heads=16, attention_head_dim=64,
62
+ dropout=0.2),
63
+ )
64
+
65
+
66
+ # ----------------------------------------------------------------------------- vision tower
67
+ class DepthAnythingTower(nn.Module):
68
+ """The DINOv2 trunk of Depth Anything V2 (the DPT neck and depth head are discarded).
69
+ Input: ImageNet-normalised 224x224 images. Output: (N, 256, D) post-layernorm patch tokens
70
+ of the last layer, CLS token removed."""
71
+
72
+ def __init__(self, name: str, pretrained: bool = True):
73
+ super().__init__()
74
+ from transformers import DepthAnythingConfig, DepthAnythingForDepthEstimation
75
+
76
+ if pretrained:
77
+ model = DepthAnythingForDepthEstimation.from_pretrained(name)
78
+ else:
79
+ model = DepthAnythingForDepthEstimation._from_config(DepthAnythingConfig.from_pretrained(name))
80
+ self.backbone = model.backbone
81
+ # Only read with bool_masked_pos, which the backbone never passes: no gradient (DDP).
82
+ self.backbone.embeddings.mask_token.requires_grad_(False)
83
+
84
+ def forward(self, pixel_values: torch.Tensor) -> torch.Tensor:
85
+ return self.backbone(pixel_values).feature_maps[-1][:, 1:]
86
+
87
+
88
+ class _DownSample(nn.Module):
89
+ """Concatenate 2x2 neighbouring tokens of a square grid: (N, h*w, C) -> (N, h*w/4, 4C)."""
90
+
91
+ def forward(self, x):
92
+ n, L, c = x.shape
93
+ h = w = int(L ** 0.5)
94
+ x = x.reshape(n, h, w, c)
95
+ if w % 2 or h % 2:
96
+ x = F.pad(x, (0, 0, 0, h % 2, 0, w % 2))
97
+ n, w, h, c = x.shape
98
+ x = x.view(n, w, h // 2, c * 2).permute(0, 2, 1, 3).contiguous()
99
+ return x.view(n, h // 2, w // 2, c * 4).reshape(n, -1, c * 4)
100
+
101
+
102
+ class MultimodalProjector(nn.Module):
103
+ """``mlp_doubledownsample``: 256 patch tokens -> 64 -> 16 tokens."""
104
+
105
+ def __init__(self, mm_hidden: int, hidden: int):
106
+ super().__init__()
107
+ self.layers = nn.Sequential(
108
+ _DownSample(), nn.LayerNorm(mm_hidden * 4), nn.Linear(mm_hidden * 4, mm_hidden),
109
+ nn.GELU(), nn.Linear(mm_hidden, mm_hidden),
110
+ _DownSample(), nn.LayerNorm(mm_hidden * 4), nn.Linear(mm_hidden * 4, hidden),
111
+ nn.GELU(), nn.Linear(hidden, hidden))
112
+
113
+ def forward(self, x):
114
+ return self.layers(x)
115
+
116
+
117
+ # ----------------------------------------------------------------------------- action head
118
+ class CategorySpecificLinear(nn.Module):
119
+ def __init__(self, num_categories, input_dim, hidden_dim):
120
+ super().__init__()
121
+ self.W = nn.Parameter(0.02 * torch.randn(num_categories, input_dim, hidden_dim))
122
+ self.b = nn.Parameter(torch.zeros(num_categories, hidden_dim))
123
+
124
+ def forward(self, x, cat_ids):
125
+ return torch.bmm(x, self.W[cat_ids]) + self.b[cat_ids].unsqueeze(1)
126
+
127
+
128
+ class CategorySpecificMLP(nn.Module):
129
+ def __init__(self, num_categories, input_dim, hidden_dim, output_dim):
130
+ super().__init__()
131
+ self.layer1 = CategorySpecificLinear(num_categories, input_dim, hidden_dim)
132
+ self.layer2 = CategorySpecificLinear(num_categories, hidden_dim, output_dim)
133
+
134
+ def forward(self, x, cat_ids):
135
+ return self.layer2(F.relu(self.layer1(x, cat_ids)), cat_ids)
136
+
137
+
138
+ def sinusoidal(timesteps: torch.Tensor, dim: int) -> torch.Tensor:
139
+ """(B, T) -> (B, T, dim) sin/cos encoding."""
140
+ timesteps = timesteps.float()
141
+ half = dim // 2
142
+ exponent = -torch.arange(half, dtype=torch.float, device=timesteps.device) * (
143
+ torch.log(torch.tensor(10000.0)) / half)
144
+ freqs = timesteps.unsqueeze(-1) * exponent.exp()
145
+ return torch.cat([torch.sin(freqs), torch.cos(freqs)], dim=-1)
146
+
147
+
148
+ class MultiEmbodimentActionEncoder(nn.Module):
149
+ def __init__(self, action_dim, hidden_size, num_embodiments):
150
+ super().__init__()
151
+ self.hidden_size = hidden_size
152
+ self.W1 = CategorySpecificLinear(num_embodiments, action_dim, hidden_size)
153
+ self.W2 = CategorySpecificLinear(num_embodiments, 2 * hidden_size, hidden_size)
154
+ self.W3 = CategorySpecificLinear(num_embodiments, hidden_size, hidden_size)
155
+
156
+ def forward(self, actions, timesteps, cat_ids):
157
+ B, T, _ = actions.shape
158
+ a_emb = self.W1(actions, cat_ids)
159
+ tau = sinusoidal(timesteps.unsqueeze(1).expand(-1, T), self.hidden_size).to(a_emb.dtype)
160
+ x = self.W2(torch.cat([a_emb, tau], dim=-1), cat_ids)
161
+ return self.W3(x * torch.sigmoid(x), cat_ids)
162
+
163
+
164
+ class IDM(nn.Module):
165
+ """Flow-matching inverse dynamics model. See the module docstring.
166
+
167
+ Batch (see ``data.collate``): ``images`` (N, 3, 224, 224) for all samples concatenated in
168
+ order [t0 view0, t0 view1, t1 view0, ...]; ``view_ids`` (N,); ``n_images`` (B,);
169
+ ``embodiment_id`` (B,); ``actions_mask`` (B, H, A) bool, True on the robot's native
170
+ dimensions; ``actions`` (B, H, A) normalised targets (training only).
171
+ """
172
+
173
+ def __init__(self, cfg: dict | None = None, pretrained_vision: bool = True):
174
+ super().__init__()
175
+ c = {**DEFAULT_MODEL_CFG, **(cfg or {})}
176
+ self.cfg = c
177
+ D = c["hidden_size"]
178
+ self.vision = DepthAnythingTower(c["vision_tower"], pretrained=pretrained_vision)
179
+ self.vision_projector = nn.Linear(c["vision_hidden_size"], D)
180
+ self.mm_projector = MultimodalProjector(c["vision_hidden_size"], D)
181
+ self.view_embedding = nn.Embedding(c["max_num_views"], D)
182
+ self.position_embedding = nn.Embedding(c["max_seq_len"], D)
183
+ nn.init.normal_(self.view_embedding.weight, std=0.02)
184
+ nn.init.normal_(self.position_embedding.weight, std=0.02)
185
+ self.vl_self_attention = SelfAttentionTransformer(**c["vl_self_attention"])
186
+ self.dit = DiT(**c["dit"])
187
+ self.action_encoder = MultiEmbodimentActionEncoder(c["action_dim"], D, c["max_num_embodiments"])
188
+ self.action_decoder = CategorySpecificMLP(c["max_num_embodiments"], D, D, c["action_dim"])
189
+ self.beta_dist = Beta(c["noise_beta_alpha"], c["noise_beta_beta"])
190
+ # One ViT layer is kept frozen, as in the trained checkpoint.
191
+ for i in c["frozen_vision_layers"]:
192
+ self.vision.backbone.encoder.layer[i].requires_grad_(False)
193
+
194
+ # -- encoding -------------------------------------------------------------------------
195
+ def encode(self, batch) -> torch.Tensor:
196
+ """Visual context (B, max_vl_tokens, D): each sample's image tokens right-aligned,
197
+ zeros in front (the zeros are attended to, exactly as in training)."""
198
+ x = self.mm_projector(self.vision_projector(self.vision(batch["images"])))
199
+ x = x + self.view_embedding(batch["view_ids"]).unsqueeze(1)
200
+ n_tok = batch["n_images"] * self.cfg["tokens_per_image"]
201
+ L = self.cfg["max_vl_tokens"]
202
+ assert int(n_tok.max()) <= L, f"{int(n_tok.max())} image tokens > max_vl_tokens={L}"
203
+ pos = torch.arange(L, device=x.device)
204
+ mask = (pos[None] >= (L - n_tok)[:, None]).unsqueeze(-1).expand(-1, -1, x.shape[-1])
205
+ vl = torch.zeros(len(n_tok), L, x.shape[-1], dtype=x.dtype, device=x.device)
206
+ return self.vl_self_attention(vl.masked_scatter(mask, x))
207
+
208
+ def _velocity(self, context, actions, t_disc, t_dit, emb_id):
209
+ sa = self.action_encoder(actions, t_disc, emb_id)
210
+ T = sa.shape[1]
211
+ sa = sa + self.position_embedding(torch.arange(T, device=sa.device)).unsqueeze(0)
212
+ out = self.dit(sa, context, t_dit)
213
+ return self.action_decoder(out, emb_id)[:, -T:]
214
+
215
+ # -- training -------------------------------------------------------------------------
216
+ def forward(self, batch) -> torch.Tensor:
217
+ """Flow-matching loss over the native (unpadded) action dimensions."""
218
+ context = self.encode(batch)
219
+ actions, mask = batch["actions"], batch["actions_mask"].to(batch["actions"].dtype)
220
+ noise = torch.randn_like(actions)
221
+ s = self.cfg["noise_s"]
222
+ t = (s - self.beta_dist.sample([actions.shape[0]]).to(actions.device, actions.dtype)) / s
223
+ t = t[:, None, None]
224
+ noisy = ((1 - t) * noise + t * actions) * mask
225
+ t_disc = (t[:, 0, 0] * self.cfg["num_timestep_buckets"]).long()
226
+ pred = self._velocity(context, noisy, t_disc, t_disc, batch["embodiment_id"])
227
+ loss = F.mse_loss(pred, actions - noise, reduction="none") * mask
228
+ return loss.sum() / mask.sum()
229
+
230
+ # -- inference ------------------------------------------------------------------------
231
+ @torch.inference_mode()
232
+ def get_action(self, batch, generator: torch.Generator | None = None) -> torch.Tensor:
233
+ """Integrate the flow from noise; returns normalised, padded actions (B, H, A)."""
234
+ emb_id = batch["embodiment_id"]
235
+ images = batch["images"]
236
+ B, H, A = len(emb_id), self.cfg["action_horizon"], self.cfg["action_dim"]
237
+ x = torch.randn((B, H, A), dtype=images.dtype, device=images.device, generator=generator)
238
+ mask = batch["actions_mask"].to(x.dtype)
239
+ x = x * mask
240
+ context = self.encode(batch)
241
+ n = self.cfg["num_inference_timesteps"]
242
+ for i in range(n):
243
+ t = int(i / float(n) * self.cfg["num_timestep_buckets"])
244
+ v = self._velocity(context, x, (torch.ones(B) * t).to(x.device),
245
+ torch.tensor([t], device=x.device, dtype=torch.long), emb_id)
246
+ x = (x + (1.0 / n) * v) * mask
247
+ return x
248
+
249
+ # -- I/O ------------------------------------------------------------------------------
250
+ def save_pretrained(self, out_dir: str | Path, metadata: dict | None = None) -> None:
251
+ from safetensors.torch import save_file
252
+
253
+ out = Path(out_dir)
254
+ out.mkdir(parents=True, exist_ok=True)
255
+ sd = {k: v.detach().contiguous().cpu() for k, v in self.state_dict().items()}
256
+ save_file(sd, str(out / "model.safetensors"), metadata={"format": "pt"})
257
+ (out / "idm_config.json").write_text(json.dumps(self.cfg, indent=1))
258
+ if metadata is not None:
259
+ (out / "metadata.json").write_text(json.dumps(metadata, indent=1))
260
+
261
+ @classmethod
262
+ def from_pretrained(cls, ckpt_dir: str | Path, device="cpu") -> "IDM":
263
+ """Load a checkpoint written by ``save_pretrained`` or by the GR00T-Dreams IDM trainer."""
264
+ from safetensors.torch import load_file
265
+
266
+ d = Path(ckpt_dir)
267
+ if (d / "idm_config.json").exists():
268
+ cfg = json.loads((d / "idm_config.json").read_text())
269
+ else:
270
+ cfg = _legacy_config(json.loads((d / "config.json").read_text()))
271
+ model = cls(cfg, pretrained_vision=False)
272
+ sd = load_file(str(d / "model.safetensors"))
273
+ if any(k.startswith("action_head.") for k in sd):
274
+ sd = _legacy_state_dict(sd)
275
+ model.load_state_dict(sd, strict=True)
276
+ return model.to(device).eval()
277
+
278
+
279
+ def load_metadata(ckpt_dir: str | Path) -> dict:
280
+ """Per-embodiment normalisation statistics saved with the checkpoint, keyed by name."""
281
+ d = Path(ckpt_dir)
282
+ for p in (d / "metadata.json", d / "experiment_cfg" / "metadata.json"):
283
+ if p.exists():
284
+ return json.loads(p.read_text())
285
+ raise FileNotFoundError(f"no metadata.json in {d}")
286
+
287
+
288
+ def _legacy_config(c: dict) -> dict:
289
+ h = c["action_head_cfg"]["config"]
290
+ keep = ("num_layers", "num_attention_heads", "attention_head_dim", "dropout")
291
+ return dict(
292
+ vision_tower=h["siglip_model_cfg"]["pretrained_model_name_or_path"],
293
+ vision_hidden_size=h["siglip_hidden_size"], hidden_size=h["hidden_size"],
294
+ action_dim=h["action_dim"], action_horizon=h["action_horizon"],
295
+ max_num_embodiments=h.get("max_num_embodiments", 32), max_num_views=h["max_num_views"],
296
+ max_seq_len=h.get("max_seq_len", 1024),
297
+ num_inference_timesteps=h["num_inference_timesteps"],
298
+ num_timestep_buckets=h["num_timestep_buckets"],
299
+ noise_beta_alpha=h["noise_beta_alpha"], noise_beta_beta=h["noise_beta_beta"],
300
+ noise_s=h["noise_s"],
301
+ dit={**{k: h["diffusion_model_cfg"][k] for k in keep},
302
+ "output_dim": h["diffusion_model_cfg"]["output_dim"],
303
+ "interleave_self_attention": h["diffusion_model_cfg"]["interleave_self_attention"]},
304
+ vl_self_attention={k: h["vl_self_attention_cfg"][k] for k in keep},
305
+ )
306
+
307
+
308
+ _LEGACY_RENAMES = (
309
+ (r"^action_head\.siglip_model\.vision_model\.backbone\.", "vision.backbone."),
310
+ (r"^action_head\.model\.", "dit."),
311
+ (r"^action_head\.vl_self_attention_model\.", "vl_self_attention."),
312
+ (r"^action_head\.", ""),
313
+ )
314
+
315
+
316
+ def _legacy_state_dict(sd: dict) -> dict:
317
+ out = {}
318
+ for k, v in sd.items():
319
+ if k.endswith(("siglip_model.logit_scale", "siglip_model.logit_bias")):
320
+ continue
321
+ for pat, rep in _LEGACY_RENAMES:
322
+ k2 = re.sub(pat, rep, k)
323
+ if k2 != k:
324
+ k = k2
325
+ break
326
+ out[k] = v
327
+ return out
robotryon/idm/wrist_correction.py ADDED
@@ -0,0 +1,229 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Wrist correction of IDM action labels (paper Sec. 3.4, App. B.2.6).
2
+
3
+ The IDM reads joint angles from pixels and places the wrist less accurately than the 3D hand
4
+ reconstruction of the human source video. For each hand we therefore move the wrist, and only
5
+ the wrist, toward the reconstruction:
6
+
7
+ 1. The reconstructed human wrist (camera frame) is mapped to the robot base frame with the
8
+ head-camera extrinsics T_base_cam.
9
+ 2. The robot hand implied by the IDM joints (FK) is scaled by ``hand_scale``
10
+ about its wrist; the target wrist is the one at which the scaled hand's end-effector keypoint
11
+ coincides with the reconstructed wrist.
12
+ 3. Targets are kept only on high-confidence frames. The offset d = target - IDM wrist is
13
+ cleaned: excursion rejection (hysteresis around a running median of accepted offsets),
14
+ linear fill of short gaps, zero-phase 2nd-order Butterworth low-pass per usable segment.
15
+ 4. Damped least squares IK on the arm joints of that side: wrist position to the IDM wrist + d,
16
+ wrist orientation held at the FK orientation of the IDM arm joints (weighted rotation
17
+ residual), damping toward the IDM arm joints, numerical Jacobian of FK, warm-started from
18
+ the previous frame of the segment.
19
+ 5. The solution is blended into the IDM joints with weights ramping 0 -> 1 over ``ramp`` frames
20
+ at segment edges (not at the clip ends), then the arm tracks get SG smoothing. Hand joints
21
+ and all other joints are left untouched.
22
+
23
+ Kinematics interface required (minimal protocol; ``robotryon.embodiment.Embodiment`` is
24
+ expected to satisfy it)::
25
+
26
+ class Kinematics(Protocol):
27
+ arm_joint_slices: dict[str, slice] # {"left": slice, "right": slice} into the action
28
+ def fk(self, q: np.ndarray) -> dict[str, dict[str, np.ndarray]]:
29
+ # {"left": {"wrist_pos": (3,), "wrist_rot": (3, 3), "keypoints": (17, 3)}, "right": ...}
30
+ # robot base frame, metres; keypoints use robotryon.skeleton order (0 = wrist)
31
+
32
+ Hands are indexed 0 = left, 1 = right, as in ``robotryon.skeleton``.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ from typing import Callable
38
+
39
+ import numpy as np
40
+ from scipy.signal import butter, filtfilt, savgol_filter
41
+
42
+ SIDES = ("left", "right")
43
+
44
+ DEFAULTS = dict(
45
+ hand_scale=1.0, ee_keypoint=0, min_confidence=0.5,
46
+ reject_enter_mm=150.0, reject_exit_mm=80.0, reject_hard_mm=500.0, reject_ref_n=15,
47
+ gap_fill=6, cutoff_hz=2.0, fps=20.0, ramp=8, smooth_window=9, smooth_order=2,
48
+ ik_iters=30, w_rot=2000.0, damping=0.5, step_cap=0.08, fd_eps=1e-3,
49
+ )
50
+
51
+
52
+ # ----------------------------------------------------------------------------- track cleaning
53
+ def segments(mask: np.ndarray) -> list[tuple[int, int]]:
54
+ """Half-open [a, b) runs of True."""
55
+ out, a = [], None
56
+ for i, m in enumerate(mask):
57
+ if m and a is None:
58
+ a = i
59
+ if not m and a is not None:
60
+ out.append((a, i))
61
+ a = None
62
+ if a is not None:
63
+ out.append((a, len(mask)))
64
+ return out
65
+
66
+
67
+ def reject_excursions(d, ok, enter, exit_, hard, ref_n=15):
68
+ """Refuse frames whose offset leaves the level it has been holding (running median of the
69
+ last ``ref_n`` accepted offsets); a refusal lasts until the offset is back within ``exit_``.
70
+ ``hard`` caps |d| before a level is established."""
71
+ good, buf, ref = ok.copy(), [], None
72
+ for t in range(len(d)):
73
+ if not ok[t]:
74
+ continue
75
+ if np.linalg.norm(d[t]) > hard:
76
+ good[t] = False
77
+ continue
78
+ if ref is None:
79
+ buf.append(d[t])
80
+ if len(buf) >= 5:
81
+ ref = np.median(np.array(buf[-ref_n:]), axis=0)
82
+ continue
83
+ dev = float(np.linalg.norm(d[t] - ref))
84
+ if dev > enter or (dev > exit_ and not good[max(t - 1, 0)]):
85
+ good[t] = False
86
+ else:
87
+ buf.append(d[t])
88
+ ref = np.median(np.array(buf[-ref_n:]), axis=0)
89
+ return good
90
+
91
+
92
+ def fill_short_gaps(track, ok, max_gap):
93
+ """Linearly interpolate refused runs of <= max_gap frames between usable frames (in place);
94
+ returns the new usable mask."""
95
+ ok = ok.copy()
96
+ for a, b in segments(~ok):
97
+ if a == 0 or b == len(ok) or b - a > max_gap:
98
+ continue
99
+ for k in range(track.shape[1]):
100
+ track[a:b, k] = np.interp(np.arange(a, b), [a - 1, b], [track[a - 1, k], track[b, k]])
101
+ ok[a:b] = True
102
+ return ok
103
+
104
+
105
+ def lowpass(track, ok, cutoff_hz, fps):
106
+ """Zero-phase Butterworth per usable segment (segments too short for filtfilt are kept)."""
107
+ b, a = butter(2, cutoff_hz / (fps / 2.0), "low")
108
+ out = track.copy()
109
+ for lo, hi in segments(ok):
110
+ if hi - lo > 3 * max(len(a), len(b)) + 2:
111
+ out[lo:hi] = np.stack([filtfilt(b, a, track[lo:hi, c]) for c in range(track.shape[1])], 1)
112
+ return out
113
+
114
+
115
+ def ramp_weights(ok, ramp):
116
+ """1 inside usable segments, ramping linearly at segment edges that are not clip ends."""
117
+ T, w = len(ok), np.zeros(len(ok))
118
+ for a, b in segments(ok):
119
+ for t in range(a, b):
120
+ up = 1.0 if a == 0 else (t - a + 1) / ramp
121
+ dn = 1.0 if b == T else (b - t) / ramp
122
+ w[t] = min(1.0, up, dn)
123
+ return w
124
+
125
+
126
+ # ----------------------------------------------------------------------------- IK
127
+ def rotvec(R_cur, R_ref):
128
+ """Axis-angle of R_cur R_ref^T."""
129
+ from scipy.spatial.transform import Rotation
130
+
131
+ return Rotation.from_matrix(R_cur @ R_ref.T).as_rotvec()
132
+
133
+
134
+ def ik_wrist(fk: Callable, q, side, sl, target_mm, R_ref, q_ref, iters=30, w_rot=2000.0,
135
+ damping=0.5, step_cap=0.08, fd_eps=1e-3):
136
+ """Damped least squares on the joints ``sl``: wrist position (mm) to ``target_mm``,
137
+ orientation pinned to ``R_ref`` (``w_rot`` mm per rad), damping toward ``q_ref``.
138
+ Returns (q, position residual in mm before the last step)."""
139
+ s = np.array(q, dtype=np.float64)
140
+ n = sl.stop - sl.start
141
+
142
+ def res(x):
143
+ h = fk(x)[side]
144
+ return np.concatenate([np.asarray(h["wrist_pos"]) * 1000.0 - target_mm,
145
+ w_rot * rotvec(np.asarray(h["wrist_rot"]), R_ref)])
146
+
147
+ lam, last = damping * 1e4, np.inf
148
+ for _ in range(iters):
149
+ r = res(s)
150
+ if not np.all(np.isfinite(r)):
151
+ return np.array(q, dtype=np.float64), np.inf
152
+ last = float(np.linalg.norm(r[:3]))
153
+ J = np.zeros((6, n))
154
+ for j in range(n):
155
+ s2 = s.copy()
156
+ s2[sl.start + j] += fd_eps
157
+ J[:, j] = (res(s2) - r) / fd_eps
158
+ if not np.all(np.isfinite(J)):
159
+ return np.array(q, dtype=np.float64), np.inf
160
+ dq = np.linalg.solve(J.T @ J + (1.0 + lam) * np.eye(n), -J.T @ r - lam * (s[sl] - q_ref[sl]))
161
+ norm = np.linalg.norm(dq)
162
+ if norm > step_cap:
163
+ dq *= step_cap / norm
164
+ s[sl] += dq
165
+ if norm < 1e-4:
166
+ break
167
+ return s, last
168
+
169
+
170
+ # ----------------------------------------------------------------------------- main entry
171
+ def camera_to_base(points_cam: np.ndarray, T_base_cam: np.ndarray) -> np.ndarray:
172
+ """(T, 2, 3) camera-frame points -> base frame; T_base_cam is (4, 4) or (T, 4, 4)."""
173
+ Tm = np.broadcast_to(np.asarray(T_base_cam, np.float64), (len(points_cam), 4, 4))
174
+ return np.einsum("tij,thj->thi", Tm[:, :3, :3], points_cam) + Tm[:, None, :3, 3]
175
+
176
+
177
+ def correct_wrists(actions: np.ndarray, kin, wrist_base: np.ndarray, confidence: np.ndarray,
178
+ cfg: dict | None = None) -> tuple[np.ndarray, dict]:
179
+ """Correct the arm joints of ``actions`` (T, D) so the wrists follow ``wrist_base``.
180
+
181
+ wrist_base: (T, 2, 3) reconstructed human wrists in the robot base frame (metres, NaN when
182
+ missing); confidence: (T, 2). Returns the corrected actions and per-side statistics.
183
+ """
184
+ c = {**DEFAULTS, **(cfg or {})}
185
+ act = np.asarray(actions, np.float64)
186
+ T = len(act)
187
+ out = act.copy()
188
+ fk_idm = [kin.fk(act[t]) for t in range(T)]
189
+ info = {}
190
+ for hi, side in enumerate(SIDES):
191
+ sl = kin.arm_joint_slices[side]
192
+ sl = sl if isinstance(sl, slice) else slice(*sl)
193
+ p_idm = np.stack([np.asarray(f[side]["wrist_pos"]) for f in fk_idm]) * 1000.0
194
+ R_idm = [np.asarray(f[side]["wrist_rot"]) for f in fk_idm]
195
+ # end effector of the scaled IDM hand, relative to its wrist
196
+ ee = np.stack([np.asarray(f[side]["keypoints"])[c["ee_keypoint"]] for f in fk_idm]) * 1000.0
197
+ target = wrist_base[:, hi] * 1000.0 - c["hand_scale"] * (ee - p_idm)
198
+ d = target - p_idm
199
+ ok = (confidence[:, hi] >= c["min_confidence"]) & np.isfinite(d).all(1)
200
+ n_conf = int(ok.sum())
201
+ d = np.where(ok[:, None], d, 0.0)
202
+ ok = reject_excursions(d, ok, c["reject_enter_mm"], c["reject_exit_mm"],
203
+ c["reject_hard_mm"], c["reject_ref_n"])
204
+ n_rej = n_conf - int(ok.sum())
205
+ ok = fill_short_gaps(d, ok, c["gap_fill"])
206
+ d = lowpass(d, ok, c["cutoff_hz"], c["fps"])
207
+ q_ik, prev, resid = act[:, sl].copy(), None, []
208
+ for t in range(T):
209
+ if not ok[t]:
210
+ prev = None
211
+ continue
212
+ cur = act[t].copy()
213
+ if prev is not None:
214
+ cur[sl] = prev
215
+ new, r = ik_wrist(kin.fk, cur, side, sl, p_idm[t] + d[t], R_idm[t], act[t],
216
+ c["ik_iters"], c["w_rot"], c["damping"], c["step_cap"], c["fd_eps"])
217
+ if not np.isfinite(r):
218
+ ok[t], prev = False, None
219
+ continue
220
+ q_ik[t] = prev = new[sl]
221
+ resid.append(r)
222
+ w = ramp_weights(ok, c["ramp"])
223
+ blended = act[:, sl] + w[:, None] * (q_ik - act[:, sl])
224
+ if T > c["smooth_window"]:
225
+ blended = savgol_filter(blended, c["smooth_window"], c["smooth_order"], axis=0, mode="nearest")
226
+ out[:, sl] = blended
227
+ info[side] = dict(confident_frames=n_conf, rejected=n_rej, corrected_frames=int((w > 0).sum()),
228
+ ik_residual_mm_median=float(np.median(resid)) if resid else None)
229
+ return out.astype(np.asarray(actions).dtype), info
robotryon/image/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Keyframe translation f_theta (paper Sec. 3.2): FLUX.2 [klein] 9B Base as a reference-guided
2
+ inpainting model.
3
+
4
+ Modules:
5
+ fill_channels widened patch embedding, channel-concatenated [noisy | conditioned frame | mask]
6
+ stereo_ids RoPE ids with the camera view on the 4th axis
7
+ episodes data layout access and model-resolution geometry
8
+ dataset training samples (reference window, robot-only exemplar, masks, skeleton)
9
+ text prompts, caption dropout and the text-embedding cache
10
+ training flow-matching loss
11
+ model FFT / LoRA build, save and load
12
+ sampler true-CFG Euler sampler returning latent tokens
13
+ ar_keyframes autoregressive translation with latent reuse
14
+ """
robotryon/image/ar_keyframes.py ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Autoregressive keyframe translation (paper Eq. 3.2, App. B.2.3).
2
+
3
+ Human keyframes are translated in temporal order. Keyframe t_j is conditioned on
4
+
5
+ * the conditioned human frame Phi(o^h; M^h, S^h): the human arm mask (convex hull per arm,
6
+ fixed dilation, eroded object removed) blacked out and the retargeted robot-hand skeleton drawn
7
+ on top, plus the mask itself as an input channel;
8
+ * the reference: for the first keyframe, the encoded robot-only reference image supplied by the
9
+ user (it selects the embodiment); for every later keyframe, the DENOISED LATENT TOKENS of the
10
+ previous keyframe, reused directly as reference tokens (no decode / re-encode).
11
+
12
+ ``ref_skeleton: true`` replaces latent reuse by a pixel-space reference: the previous keyframe is
13
+ decoded, the current keyframe's skeleton is painted on it, and the result is re-encoded (the first
14
+ keyframe's reference image is painted the same way).
15
+
16
+ Stereo episodes are translated jointly (both views in one token sequence) by default; ``views:
17
+ mono`` translates each view as its own chain.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import json
22
+ from dataclasses import dataclass
23
+ from pathlib import Path
24
+
25
+ import numpy as np
26
+ import torch
27
+ from PIL import Image
28
+
29
+ from ..masks import MaskAug, inference_mask
30
+ from ..skeleton import condition_frame, draw_skeleton, paint
31
+ from .episodes import Episode, FrameReader, load_view_frame
32
+ from .fill_channels import pack
33
+ from .sampler import LatentCodec, ViewCondition, denoise
34
+ from ..captions import HandStateCuts, hand_state_arrays, hand_state_prompt, load_hand_state, words_at
35
+
36
+
37
+ @dataclass
38
+ class TranslatedKeyframe:
39
+ image: np.ndarray # uint8 (H, W, 3) robot keyframe
40
+ cond: np.ndarray # uint8 (H, W, 3) conditioned human frame
41
+ mask: np.ndarray # uint8 (H, W) {0, 1} inference mask
42
+ tokens: torch.Tensor # (1, h*w, 128) denoised latent tokens (next keyframe's reference)
43
+
44
+
45
+ class KeyframeTranslator:
46
+ def __init__(self, pipe, cfg, device="cuda"):
47
+ self.pipe, self.cfg, self.device = pipe, cfg, device
48
+ self.codec = LatentCodec(pipe.vae, device)
49
+ self.reader = FrameReader()
50
+ self.dilate = MaskAug.from_config(cfg.masks).infer_dilate()
51
+ self.ref_skeleton = bool(cfg.get("ref_skeleton", False))
52
+ self._prompts: dict[str, tuple] = {}
53
+
54
+ def prompt_fn(self, ep: Episode, episode_dir: Path):
55
+ """``prompt(t)``: the instruction and each hand's closure / facing words at keyframe t
56
+ (``robotryon.captions``), read from the episode's ``hand_state_<view>.npz`` (or computed
57
+ from ``hands.npz`` when absent)."""
58
+ cuts = HandStateCuts.from_config(self.cfg.caption.get("cuts"))
59
+ states = []
60
+ for i, v in enumerate(ep.views):
61
+ f = episode_dir / f"hand_state_{v}.npz"
62
+ if f.exists():
63
+ states.append(load_hand_state(f))
64
+ if not states:
65
+ z = np.load(episode_dir / "hands.npz")
66
+ states = [hand_state_arrays(z["joints3d"][i], z["valid"][i].astype(bool))
67
+ for i in range(z["joints3d"].shape[0])]
68
+ return lambda t: hand_state_prompt(*words_at(states, int(t), cuts))
69
+
70
+ def encode_prompt(self, text: str):
71
+ if text not in self._prompts:
72
+ self._prompts[text] = self.pipe.encode_prompt(prompt=text, device=self.device)
73
+ return self._prompts[text]
74
+
75
+ def condition(self, ep: Episode, view: str, t: int):
76
+ """(frame, conditioned frame, mask, skeleton image) of one human keyframe at model resolution."""
77
+ c = self.cfg
78
+ f = load_view_frame(self.reader, ep, view, t, int(c.image.long_edge),
79
+ tuple(c.image.aspect) if c.image.get("aspect") else None,
80
+ c.image.get("crop_anchor", "bottom"))
81
+ H, W = f.image.shape[:2]
82
+ mask = inference_mask(f.hulls, W, H, self.dilate, f.object_mask, int(c.masks.obj_erode))
83
+ skel = draw_skeleton(f.kp, f.vis, W, H)
84
+ cond = condition_frame(f.image, mask, skel)
85
+ return f.image, cond, mask, skel
86
+
87
+ def reference(self, view: str, prev: dict, references: dict, skel: np.ndarray) -> torch.Tensor:
88
+ """Reference tokens of one view for the current keyframe."""
89
+ if view in prev and not self.ref_skeleton:
90
+ return prev[view].tokens # latent reuse
91
+ if view in prev:
92
+ img = prev[view].image
93
+ else:
94
+ H, W = skel.shape[:2]
95
+ img = np.asarray(Image.fromarray(references[view]).resize((W, H), Image.LANCZOS))
96
+ return pack(self.codec.encode(paint(img, skel) if self.ref_skeleton else img))
97
+
98
+ @torch.no_grad()
99
+ def translate_chain(self, views: list[str], keyframes: list[int], references: dict[str, np.ndarray],
100
+ prompt, conditions) -> dict:
101
+ """One chain over ``keyframes``; ``views`` are denoised jointly.
102
+
103
+ ``prompt`` is a string or ``prompt(t)`` giving each keyframe its own prompt.
104
+ ``conditions(view, t)`` returns ``(frame, conditioned frame, mask, skeleton image)``.
105
+ Returns ``{view: {t: TranslatedKeyframe}}``.
106
+ """
107
+ c = self.cfg
108
+ prompt_at = prompt if callable(prompt) else (lambda t: prompt)
109
+ neg, neg_ids = self.encode_prompt("") if float(c.guidance_scale) > 1.0 else (None, None)
110
+ prev: dict[str, TranslatedKeyframe] = {}
111
+ out: dict[str, dict] = {v: {} for v in views}
112
+ for t in keyframes:
113
+ conds, frames = [], []
114
+ for v in views:
115
+ _, cond, mask, skel = conditions(v, t)
116
+ mask_t = torch.from_numpy(mask.astype(np.float32))[None, None].to(self.device)
117
+ conds.append(ViewCondition(self.codec.encode(cond), mask_t,
118
+ self.reference(v, prev, references, skel)))
119
+ frames.append((cond, mask))
120
+ pos, pos_ids = self.encode_prompt(prompt_at(t))
121
+ gen = torch.Generator(device=self.device).manual_seed(int(c.seed) + int(t))
122
+ tokens = denoise(self.pipe.transformer, self.pipe.scheduler, conds, pos, pos_ids, neg, neg_ids,
123
+ steps=int(c.steps), guidance_scale=float(c.guidance_scale), generator=gen)
124
+ for v, tok, (cond, mask) in zip(views, tokens, frames):
125
+ H, W = mask.shape
126
+ img = self.codec.decode_tokens(tok, H // 16, W // 16)
127
+ out[v][t] = prev[v] = TranslatedKeyframe(img, cond, mask, tok)
128
+ return out
129
+
130
+ def translate_episode(self, episode_dir: str | Path, references: dict[str, np.ndarray],
131
+ out_dir: str | Path) -> Path:
132
+ """Translate every keyframe of a preprocessed human episode and write the results."""
133
+ c = self.cfg
134
+ ep = Episode.load(episode_dir)
135
+ kf = json.loads((Path(episode_dir) / "keyframes.json").read_text())["indices"]
136
+ keyframes = sorted({int(i) for i in kf} | {0})
137
+ stereo = len(ep.views) >= 2 and c.views in ("auto", "stereo")
138
+ if c.views == "stereo" and not stereo:
139
+ raise ValueError(f"{episode_dir}: views=stereo needs two camera views, found {ep.views}")
140
+ chains = [ep.views[:2]] if stereo else [[v] for v in ep.views]
141
+ missing = [v for v in ep.views if v not in references]
142
+ if missing:
143
+ raise ValueError(f"no reference image for view(s) {missing}")
144
+ prompt_at = self.prompt_fn(ep, Path(episode_dir))
145
+ prompts = {int(t): prompt_at(t) for t in keyframes}
146
+ out = Path(out_dir) / ep.path.name
147
+ out.mkdir(parents=True, exist_ok=True)
148
+ for views in chains:
149
+ res = self.translate_chain(views, keyframes, references, prompt_at,
150
+ lambda v, t: self.condition(ep, v, t))
151
+ for v, per_t in res.items():
152
+ for t, k in per_t.items():
153
+ Image.fromarray(k.image).save(out / f"keyframe_{v}_{t:06d}.png")
154
+ Image.fromarray(k.cond).save(out / f"cond_{v}_{t:06d}.png")
155
+ Image.fromarray(k.mask * 255).save(out / f"mask_{v}_{t:06d}.png")
156
+ (out / "keyframes.json").write_text(json.dumps({
157
+ "indices": keyframes, "views": ep.views, "joint_stereo": stereo, "prompts": prompts,
158
+ "ref_skeleton": self.ref_skeleton, "steps": int(c.steps),
159
+ "guidance_scale": float(c.guidance_scale),
160
+ "seed": int(c.seed)}, indent=1))
161
+ return out
robotryon/image/dataset.py ADDED
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training samples for the keyframe-translation model (paper Sec. 3.2, App. B.2.3).
2
+
3
+ A sample is a robot frame o_t of one episode, seen by one camera (mono) or both (stereo):
4
+
5
+ * target o_t
6
+ * cond Phi(o_t; M_t, S_t): the randomised shape-agnostic mask blacked out, the augmented
7
+ hand skeleton (rendered on the fly from keypoints) drawn on top
8
+ * mask M_t
9
+ * reference o_t' with t' drawn uniformly from [t - 5 s, t - 2 s] of the same episode (frame 0 if
10
+ the target is too early). With probability ``p_robot_only`` the reference is instead a
11
+ "robot-only" exemplar: a frame of ANOTHER episode of the same embodiment with
12
+ everything but the robot (its tight mask, eroded) filled with white. This is
13
+ the reference the first keyframe gets at inference.
14
+ * prompt the hand-state prompt of the target frame (each hand's closure / facing words from the
15
+ episode's ``hand_state_<view>.npz``; see ``robotryon.captions``), as a cached
16
+ embedding.
17
+
18
+ With probability ``ref_skeleton_p`` the target's (augmented) skeleton is also painted on the
19
+ reference, sequential or robot-only alike; otherwise the reference is left clean.
20
+
21
+ The two views of a stereo sample share every random draw (mask dilations, skeleton corruption,
22
+ reference frame, field-of-view crop): they are two views of one instant.
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import numpy as np
27
+ import torch
28
+ from PIL import Image
29
+
30
+ from ..masks import MaskAug, fov_augment, robot_only
31
+ from ..skeleton import SkeletonAugment, condition_frame, draw_skeleton, paint
32
+ from .episodes import Episode, FrameReader, crop_resize, load_view_frame, output_geometry, scan_root
33
+ from ..captions import HandStateCuts, all_prompts, load_hand_state, sample_prompt, words_at
34
+ from .text import TextEmbedCache
35
+
36
+
37
+ def _to_tensor(img: Image.Image) -> torch.Tensor:
38
+ a = np.asarray(img, dtype=np.float32)
39
+ if a.ndim == 2:
40
+ return torch.from_numpy(a / 255.0)[None]
41
+ return torch.from_numpy(a / 127.5 - 1.0).permute(2, 0, 1).contiguous()
42
+
43
+
44
+ class KeyframeDataset(torch.utils.data.Dataset):
45
+ """Indexed by ``(episode_index, frame, view_indices, seed)`` tuples from :class:`FillBatchSampler`."""
46
+
47
+ def __init__(self, cfg, text_cache: TextEmbedCache | None):
48
+ d = cfg.data
49
+ roots = d.roots if isinstance(d.roots, (list, tuple)) else [d.roots]
50
+ self.root_weights = []
51
+ self.episodes: list[Episode] = []
52
+ for i, r in enumerate(roots):
53
+ path, w = (r.path, float(r.get("weight", 1.0))) if isinstance(r, dict) else (r, 1.0)
54
+ eps = scan_root(path, i)
55
+ if not eps:
56
+ raise FileNotFoundError(f"no episodes (*/meta.json) under {path}")
57
+ self.episodes += eps
58
+ self.root_weights.append(w)
59
+ self.long_edge = int(cfg.image.long_edge)
60
+ self.aspect = tuple(cfg.image.aspect) if cfg.image.get("aspect") else None
61
+ self.anchor = cfg.image.get("crop_anchor", "bottom")
62
+ self.ref_window = tuple(d.ref_window_sec)
63
+ self.p_robot_only = float(d.p_robot_only)
64
+ self.ref_skeleton_p = float(d.ref_skeleton_p)
65
+ self.robot_only_erode = int(d.robot_only.erode)
66
+ self.robot_only_bg = int(d.robot_only.background)
67
+ self.robot_only_min_area = float(d.robot_only.min_area)
68
+ self.bonus_tags = set(cfg.masks.get("bonus_embodiments", []) or [])
69
+ self.mask_aug = MaskAug.from_config(cfg.masks)
70
+ self.fov = cfg.masks.fov
71
+ self.skel_aug = SkeletonAugment.from_config(cfg.skeleton.augment)
72
+ self.thickness = tuple(cfg.skeleton.thickness)
73
+ self.cap = cfg.caption
74
+ self.text_cache = text_cache
75
+ self.reader: FrameReader | None = None
76
+ self.by_embodiment: dict[str, list[int]] = {}
77
+ for i, ep in enumerate(self.episodes):
78
+ self.by_embodiment.setdefault(ep.embodiment, []).append(i)
79
+
80
+ def __len__(self) -> int:
81
+ return len(self.episodes)
82
+
83
+ def hand_states(self, ep: Episode, views: list[str]) -> list:
84
+ return [load_hand_state(ep.path / f"hand_state_{v}.npz") for v in views
85
+ if (ep.path / f"hand_state_{v}.npz").exists()]
86
+
87
+ def all_prompts(self) -> set[str]:
88
+ return set(all_prompts())
89
+
90
+ # -- reference ------------------------------------------------------------------------------
91
+ def reference_index(self, ep: Episode, t: int, rng: np.random.Generator) -> int:
92
+ lo, hi = self.ref_window
93
+ newest = t - int(round(lo * ep.fps))
94
+ oldest = max(0, t - int(round(hi * ep.fps)))
95
+ if newest < oldest:
96
+ return 0
97
+ return int(rng.integers(oldest, newest + 1))
98
+
99
+ def draw_exemplar(self, ep_idx: int, views: list[str], rng) -> tuple[int, int] | None:
100
+ """(episode, frame) of another episode of the same embodiment with a visible robot."""
101
+ ep = self.episodes[ep_idx]
102
+ pool = [i for i in self.by_embodiment.get(ep.embodiment, [])
103
+ if i != ep_idx and all(v in self.episodes[i].views for v in views)]
104
+ if not pool:
105
+ return None
106
+ ex = int(pool[int(rng.integers(0, len(pool)))])
107
+ ex_ep = self.episodes[ex]
108
+ for _ in range(8):
109
+ t = int(rng.integers(0, ex_ep.num_frames))
110
+ m = self.reader.binary(ex_ep.mask(views[0]), t)
111
+ if m.mean() >= self.robot_only_min_area:
112
+ return ex, t
113
+ return None
114
+
115
+ def robot_only(self, ex: int, t: int, view: str, size) -> np.ndarray:
116
+ ep = self.episodes[ex]
117
+ rgb = self.reader.rgb(ep.video(view), t)
118
+ src = (rgb.shape[1], rgb.shape[0])
119
+ box, _ = output_geometry(src[0], src[1], self.long_edge, self.aspect, self.anchor)
120
+ img = crop_resize(rgb, box, size, src)
121
+ m = crop_resize(self.reader.binary(ep.mask(view), t).astype(np.uint8) * 255, box, size, src)
122
+ return robot_only(img, m > 127, self.robot_only_erode, self.robot_only_bg)
123
+
124
+ # -- one view -------------------------------------------------------------------------------
125
+ def view_streams(self, ep: Episode, view: str, t: int, ref_t: int, exemplar, ref_skeleton: bool,
126
+ rng) -> dict:
127
+ f = load_view_frame(self.reader, ep, view, t, self.long_edge, self.aspect, self.anchor)
128
+ H, W = f.image.shape[:2]
129
+ mask, _ = self.mask_aug.train_mask(f.hulls, W, H, rng, bonus=ep.embodiment in self.bonus_tags,
130
+ object_mask=f.object_mask)
131
+ kp, vis = self.skel_aug(f.kp, f.vis, rng)
132
+ th = int(rng.integers(self.thickness[0], self.thickness[1] + 1))
133
+ skel = draw_skeleton(kp, vis, W, H, thickness=th, radius=max(1, int(round(th * 1.5))))
134
+ cond = condition_frame(f.image, mask, skel)
135
+ if exemplar is not None:
136
+ ref = self.robot_only(exemplar[0], exemplar[1], view, (W, H))
137
+ else:
138
+ rgb = self.reader.rgb(ep.video(view), ref_t)
139
+ src = (rgb.shape[1], rgb.shape[0])
140
+ box, _ = output_geometry(src[0], src[1], self.long_edge, self.aspect, self.anchor)
141
+ ref = crop_resize(rgb, box, (W, H), src)
142
+ if ref_skeleton:
143
+ ref = paint(ref, skel)
144
+ return {"target": f.image, "cond": cond, "mask": mask * 255, "reference": ref,
145
+ "has_hull": bool(f.hulls)}
146
+
147
+ def __getitem__(self, item) -> dict:
148
+ ep_idx, t, view_idx, seed = item
149
+ if self.reader is None:
150
+ self.reader = FrameReader()
151
+ ep = self.episodes[ep_idx]
152
+ views = [ep.views[i] for i in view_idx]
153
+ rng = np.random.default_rng([int(seed), 0])
154
+ exemplar = None
155
+ if float(rng.random()) < self.p_robot_only:
156
+ exemplar = self.draw_exemplar(ep_idx, views, rng)
157
+ ref_skeleton = float(rng.random()) < self.ref_skeleton_p
158
+ for attempt in range(8): # prefer frames where the arm is visible
159
+ ref_t = self.reference_index(ep, t, rng)
160
+ out = [self.view_streams(ep, v, t, ref_t, exemplar, ref_skeleton,
161
+ np.random.default_rng([int(seed), 1, attempt])) for v in views]
162
+ if out[0]["has_hull"]:
163
+ break
164
+ t = int(rng.integers(0, ep.num_frames))
165
+ keys = ("target", "cond", "mask", "reference")
166
+ imgs = [Image.fromarray(np.asarray(o[k], np.uint8)) for o in out for k in keys]
167
+ imgs, _ = fov_augment(imgs, rng, float(self.fov.crop_min), float(self.fov.scale_max),
168
+ float(self.fov.get("flip_prob", 0.0)))
169
+ n = len(keys)
170
+ sample = {k: torch.stack([_to_tensor(imgs[v * n + j]) for v in range(len(views))])
171
+ for j, k in enumerate(keys)}
172
+ words = words_at(self.hand_states(ep, views), t, HandStateCuts.from_config(self.cap.get("cuts")))
173
+ prompt = sample_prompt(words, rng, float(self.cap.p_uncond), float(self.cap.p_drop_hands))
174
+ sample["prompt"] = prompt
175
+ if self.text_cache is not None:
176
+ sample["prompt_embeds"] = self.text_cache.load(prompt)
177
+ sample["robot_only"] = exemplar is not None
178
+ sample["ref_skeleton"] = ref_skeleton
179
+ return sample
180
+
181
+
182
+ def collate(batch: list[dict]) -> dict:
183
+ out = {k: torch.stack([b[k] for b in batch]) for k in ("target", "cond", "mask", "reference")}
184
+ if "prompt_embeds" in batch[0]:
185
+ out["prompt_embeds"] = torch.stack([b["prompt_embeds"] for b in batch])
186
+ out["prompt"] = [b["prompt"] for b in batch]
187
+ out["robot_only"] = [b["robot_only"] for b in batch]
188
+ return out
189
+
190
+
191
+ class FillBatchSampler(torch.utils.data.Sampler):
192
+ """Endless stream of homogeneous batches for one rank.
193
+
194
+ A batch is all-stereo or all-mono (the token sequence length depends on it): stereo with
195
+ probability ``stereo_p`` (paper: stereo : mono = 3 : 1) when stereo episodes exist. Items pick
196
+ a data root by weight, an episode uniformly within it, a frame uniformly, and for mono a view
197
+ uniformly. Each rank draws its own stream from ``(seed, rank)``.
198
+ """
199
+
200
+ def __init__(self, dataset: KeyframeDataset, batch_size: int, stereo_p: float, seed: int,
201
+ rank: int = 0):
202
+ self.ds, self.bs, self.stereo_p = dataset, int(batch_size), float(stereo_p)
203
+ self.rng = np.random.default_rng([int(seed), int(rank)])
204
+ eps = dataset.episodes
205
+ n_roots = len(dataset.root_weights)
206
+ self.pools = {s: [[i for i, e in enumerate(eps) if e.root == r and (len(e.views) >= 2 or not s)]
207
+ for r in range(n_roots)] for s in (True, False)}
208
+ self.has_stereo = any(self.pools[True])
209
+
210
+ def _root_probs(self, stereo: bool) -> np.ndarray:
211
+ w = np.array([wt if self.pools[stereo][r] else 0.0
212
+ for r, wt in enumerate(self.ds.root_weights)], np.float64)
213
+ return w / w.sum()
214
+
215
+ def __iter__(self):
216
+ while True:
217
+ stereo = self.has_stereo and float(self.rng.random()) < self.stereo_p
218
+ p = self._root_probs(stereo)
219
+ batch = []
220
+ for r in self.rng.choice(len(p), size=self.bs, p=p):
221
+ pool = self.pools[stereo][int(r)]
222
+ e = pool[int(self.rng.integers(0, len(pool)))]
223
+ ep = self.ds.episodes[e]
224
+ t = int(self.rng.integers(0, ep.num_frames))
225
+ views = (0, 1) if stereo else (int(self.rng.integers(0, len(ep.views))),)
226
+ batch.append((e, t, views, int(self.rng.integers(0, 2 ** 31))))
227
+ yield batch
robotryon/image/episodes.py ADDED
@@ -0,0 +1,173 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Episode directories in the shared RoboTryOn data layout, and random frame access.
2
+
3
+ ::
4
+
5
+ <root>/<episode_id>/
6
+ meta.json {"fps", "views", "num_frames", "caption", "embodiment"}
7
+ video_<view>.mp4 RGB
8
+ keypoints_<view>.npz kp (T, 2, 17, 2) px at video resolution, vis (T, 2, 17)
9
+ mask_<view>.mp4 tight arm + hand mask (gray, > 127 = masked)
10
+ object_mask_<view>.mp4 optional manipulated-object mask
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ from collections import OrderedDict
16
+ from dataclasses import dataclass, field
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ import numpy as np
21
+ from PIL import Image
22
+
23
+
24
+ @dataclass
25
+ class Episode:
26
+ path: Path
27
+ views: list[str]
28
+ num_frames: int
29
+ fps: float
30
+ caption: str = ""
31
+ embodiment: str = ""
32
+ root: int = 0
33
+ extra: dict = field(default_factory=dict)
34
+
35
+ @classmethod
36
+ def load(cls, path: str | Path, root: int = 0) -> "Episode":
37
+ path = Path(path)
38
+ m = json.loads((path / "meta.json").read_text())
39
+ return cls(path=path, views=list(m["views"]), num_frames=int(m["num_frames"]),
40
+ fps=float(m["fps"]), caption=str(m.get("caption", "")),
41
+ embodiment=str(m.get("embodiment", "")), root=root, extra=m)
42
+
43
+ def video(self, view: str) -> Path:
44
+ return self.path / f"video_{view}.mp4"
45
+
46
+ def mask(self, view: str) -> Path:
47
+ return self.path / f"mask_{view}.mp4"
48
+
49
+ def object_mask(self, view: str) -> Path | None:
50
+ p = self.path / f"object_mask_{view}.mp4"
51
+ return p if p.exists() else None
52
+
53
+ def keypoints(self, view: str) -> Path:
54
+ return self.path / f"keypoints_{view}.npz"
55
+
56
+
57
+ def scan_root(root: str | Path, root_index: int = 0) -> list[Episode]:
58
+ return [Episode.load(p.parent, root_index) for p in sorted(Path(root).glob("*/meta.json"))]
59
+
60
+
61
+ class FrameReader:
62
+ """Small LRU of open ``cv2.VideoCapture`` objects and keypoint files (one per worker)."""
63
+
64
+ def __init__(self, max_open: int = 16):
65
+ self._caps: OrderedDict[str, cv2.VideoCapture] = OrderedDict()
66
+ self._kps: OrderedDict[str, tuple[np.ndarray, np.ndarray]] = OrderedDict()
67
+ self.max_open = max_open
68
+
69
+ def _cap(self, path: Path) -> cv2.VideoCapture:
70
+ key = str(path)
71
+ if key in self._caps:
72
+ self._caps.move_to_end(key)
73
+ return self._caps[key]
74
+ cap = cv2.VideoCapture(key)
75
+ if not cap.isOpened():
76
+ raise FileNotFoundError(f"cannot open video {key}")
77
+ self._caps[key] = cap
78
+ while len(self._caps) > self.max_open:
79
+ self._caps.popitem(last=False)[1].release()
80
+ return cap
81
+
82
+ def rgb(self, path: Path, t: int) -> np.ndarray:
83
+ cap = self._cap(path)
84
+ cap.set(cv2.CAP_PROP_POS_FRAMES, int(t))
85
+ ok, bgr = cap.read()
86
+ if not ok:
87
+ raise RuntimeError(f"cannot decode frame {t} of {path}")
88
+ return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
89
+
90
+ def binary(self, path: Path, t: int) -> np.ndarray:
91
+ """Gray mask frame -> bool ``(H, W)`` (> 127)."""
92
+ return self.rgb(path, t).max(axis=-1) > 127
93
+
94
+ def keypoints(self, path: Path) -> tuple[np.ndarray, np.ndarray]:
95
+ key = str(path)
96
+ if key not in self._kps:
97
+ z = np.load(key)
98
+ self._kps[key] = (z["kp"].astype(np.float32), z["vis"].astype(bool))
99
+ while len(self._kps) > 4 * self.max_open:
100
+ self._kps.popitem(last=False)
101
+ return self._kps[key]
102
+
103
+
104
+ def output_geometry(src_w: int, src_h: int, long_edge: int, aspect: tuple[int, int] | None,
105
+ anchor: str = "bottom") -> tuple[tuple[int, int, int, int], tuple[int, int]]:
106
+ """Crop box ``(x0, y0, w, h)`` in source pixels and output size ``(W, H)``.
107
+
108
+ A source with a different aspect ratio than ``aspect`` is cropped to it first ("bottom" keeps
109
+ the bottom edge, where the arms enter; "center" crops symmetrically). The long edge is then
110
+ resized to ``long_edge`` and both sides snapped to multiples of 16 (VAE /8 and 2x2 packing).
111
+ """
112
+ x0, y0, w, h = 0, 0, src_w, src_h
113
+ if aspect is not None and abs(src_w / src_h - aspect[0] / aspect[1]) > 1e-3:
114
+ if src_w / src_h < aspect[0] / aspect[1]: # too tall: crop height
115
+ h = int(round(src_w * aspect[1] / aspect[0]))
116
+ y0 = src_h - h if anchor == "bottom" else (src_h - h) // 2
117
+ else: # too wide: crop width, centred
118
+ w = int(round(src_h * aspect[0] / aspect[1]))
119
+ x0 = (src_w - w) // 2
120
+ s = long_edge / max(w, h)
121
+ snap = lambda v: max(16, int(round(v / 16) * 16)) # noqa: E731
122
+ return (x0, y0, w, h), (snap(w * s), snap(h * s))
123
+
124
+
125
+ def crop_resize(img: np.ndarray, box, size, src_size, nearest: bool = False) -> np.ndarray:
126
+ """Crop ``box`` (source-frame pixels) out of ``img`` and resize to ``size``.
127
+
128
+ ``img`` may have a different resolution than the source frame ``src_size`` (e.g. a mask video
129
+ stored smaller); the box is rescaled accordingly. RGB uses PIL Lanczos (as the original
130
+ pipeline did), masks use nearest neighbour.
131
+ """
132
+ fx, fy = img.shape[1] / src_size[0], img.shape[0] / src_size[1]
133
+ x0, y0 = int(round(box[0] * fx)), int(round(box[1] * fy))
134
+ x1, y1 = int(round((box[0] + box[2]) * fx)), int(round((box[1] + box[3]) * fy))
135
+ sub = img[y0:y1, x0:x1]
136
+ if nearest:
137
+ return cv2.resize(sub.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST)
138
+ pil = Image.fromarray(sub)
139
+ return np.asarray(pil if pil.size == tuple(size) else pil.resize(size, Image.LANCZOS))
140
+
141
+
142
+ @dataclass
143
+ class ViewFrame:
144
+ """One camera frame mapped to the model resolution.
145
+
146
+ image: uint8 ``(H, W, 3)``; hulls: per-arm convex hulls in output pixels; kp/vis: hand
147
+ keypoints in output pixels; object_mask: uint8 ``(H, W)`` or None.
148
+ """
149
+ image: np.ndarray
150
+ hulls: list
151
+ kp: np.ndarray
152
+ vis: np.ndarray
153
+ object_mask: np.ndarray | None
154
+
155
+
156
+ def load_view_frame(reader: FrameReader, ep: Episode, view: str, t: int, long_edge: int,
157
+ aspect=None, anchor: str = "bottom", with_object: bool = True) -> ViewFrame:
158
+ from ..masks import arm_hulls, scale_hulls
159
+ rgb = reader.rgb(ep.video(view), t)
160
+ src = (rgb.shape[1], rgb.shape[0])
161
+ box, size = output_geometry(src[0], src[1], long_edge, aspect, anchor)
162
+ sx, sy = size[0] / box[2], size[1] / box[3]
163
+ image = crop_resize(rgb, box, size, src)
164
+ m = reader.binary(ep.mask(view), t)
165
+ mx, my = src[0] / m.shape[1], src[1] / m.shape[0] # mask px -> source px
166
+ hulls = scale_hulls(arm_hulls(m), mx * sx, my * sy, (box[0] / mx, box[1] / my))
167
+ kp, vis = reader.keypoints(ep.keypoints(view))
168
+ kp_t = (kp[t] - np.array(box[:2], np.float32)) * np.array([sx, sy], np.float32)
169
+ om = None
170
+ if with_object and ep.object_mask(view) is not None:
171
+ om = crop_resize(reader.binary(ep.object_mask(view), t).astype(np.uint8), box, size, src,
172
+ nearest=True)
173
+ return ViewFrame(image, hulls, kp_t, vis[t].copy(), om)
robotryon/image/fill_channels.py ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Channel-concatenated inpainting conditioning for the FLUX.2 [klein] transformer.
2
+
3
+ The conditioned frame and the mask are pixel-aligned with the target, so they ride the CHANNEL
4
+ axis of the target tokens (FLUX-Fill style)::
5
+
6
+ target token = [ noisy latent (128) | conditioned-frame latent (128) | folded mask (256) ]
7
+
8
+ The patch embedding ``x_embedder`` is ``Linear(128, inner_dim)`` in the base model and is applied
9
+ once to the whole token sequence, so it is widened to 512 input features. The new columns are
10
+ zero-initialised: before training the widened model reproduces the base model exactly, and a
11
+ conditioning stream it cannot use degrades to "ignored" rather than to noise. The widened
12
+ embedding is always trained in full (FFT and LoRA).
13
+
14
+ Reference tokens are spatially unaligned with the target and ride the SEQUENCE axis instead; they
15
+ carry only the 128 real latent channels and are zero-padded to 512, which contributes exactly
16
+ what the base model would because the new columns start at zero.
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ import torch.nn.functional as F
23
+
24
+ BASE_IN = 128 # 32 VAE latent channels x 2x2 patchify
25
+ MASK_IN = 256 # 8x8 pixel fold -> 64 channels at H/8, then 2x2 patchify
26
+ FILL_IN = BASE_IN + BASE_IN + MASK_IN # 512
27
+
28
+
29
+ def widen_x_embedder(transformer, new_in: int = FILL_IN) -> nn.Linear:
30
+ """Grow ``transformer.x_embedder`` in place (old columns kept, new ones zero). Idempotent.
31
+
32
+ Also records the new width in the model config (``in_channels=512, out_channels=128``) so a
33
+ full checkpoint written with ``save_pretrained`` reloads with ``from_pretrained`` directly.
34
+ """
35
+ old: nn.Linear = transformer.x_embedder
36
+ if old.in_features == new_in:
37
+ return old
38
+ if old.in_features != BASE_IN:
39
+ raise ValueError(f"x_embedder has {old.in_features} inputs, expected {BASE_IN} or {new_in}")
40
+ new = nn.Linear(new_in, old.out_features, bias=old.bias is not None,
41
+ device=old.weight.device, dtype=old.weight.dtype)
42
+ with torch.no_grad():
43
+ new.weight.zero_()
44
+ new.weight[:, :BASE_IN].copy_(old.weight)
45
+ if old.bias is not None:
46
+ new.bias.copy_(old.bias)
47
+ transformer.x_embedder = new
48
+ out_ch = transformer.config.out_channels or transformer.config.in_channels
49
+ transformer.register_to_config(in_channels=new_in, out_channels=out_ch)
50
+ return new
51
+
52
+
53
+ def fold_mask_to_latent(mask: torch.Tensor, vae_scale: int = 8) -> torch.Tensor:
54
+ """``(B, 1, H, W)`` mask in [0, 1] -> ``(B, 256, H/16, W/16)``.
55
+
56
+ The mask is not interpolated: each 8x8 pixel block is folded into 64 channels (as in FLUX
57
+ Fill) so sub-latent edge precision survives, then the same 2x2 patchify as the latents.
58
+ """
59
+ b, c, h, w = mask.shape
60
+ if c != 1:
61
+ raise ValueError(f"expected a single-channel mask, got {c}")
62
+ if h % (vae_scale * 2) or w % (vae_scale * 2):
63
+ raise ValueError(f"mask {h}x{w} must be a multiple of {vae_scale * 2}")
64
+ lh, lw = h // vae_scale, w // vae_scale
65
+ m = mask.view(b, lh, vae_scale, lw, vae_scale)
66
+ m = m.permute(0, 2, 4, 1, 3).reshape(b, vae_scale * vae_scale, lh, lw)
67
+ m = m.view(b, vae_scale * vae_scale, lh // 2, 2, lw // 2, 2)
68
+ return m.permute(0, 1, 3, 5, 2, 4).reshape(b, vae_scale * vae_scale * 4, lh // 2, lw // 2)
69
+
70
+
71
+ def pad_condition_tokens(packed: torch.Tensor, new_in: int = FILL_IN) -> torch.Tensor:
72
+ """Zero-pad ``(B, N, 128)`` reference tokens to the widened embedding width."""
73
+ if packed.shape[-1] == new_in:
74
+ return packed
75
+ return F.pad(packed, (0, new_in - packed.shape[-1]))
76
+
77
+
78
+ def pack(latents: torch.Tensor) -> torch.Tensor:
79
+ """``(B, C, h, w)`` -> ``(B, h*w, C)`` tokens (same as ``Flux2KleinPipeline._pack_latents``)."""
80
+ b, c, h, w = latents.shape
81
+ return latents.reshape(b, c, h * w).permute(0, 2, 1)
82
+
83
+
84
+ def unpack(tokens: torch.Tensor, h: int, w: int) -> torch.Tensor:
85
+ """Inverse of :func:`pack`."""
86
+ b, n, c = tokens.shape
87
+ return tokens.permute(0, 2, 1).reshape(b, c, h, w)
88
+
89
+
90
+ def fill_tokens(noisy: torch.Tensor, cond: torch.Tensor, mask: torch.Tensor) -> torch.Tensor:
91
+ """Target tokens ``[noisy | cond | mask]`` from latents ``(B,128,h,w)`` and pixel mask ``(B,1,H,W)``."""
92
+ return torch.cat([pack(noisy), pack(cond), pack(fold_mask_to_latent(mask.to(cond.dtype)))], dim=-1)
robotryon/image/model.py ADDED
@@ -0,0 +1,138 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Build, save and load the keyframe-translation model f_theta (FLUX.2 [klein] 9B Base).
2
+
3
+ Two training modes share everything but the trainable set:
4
+
5
+ * ``fft``: the whole transformer is trained (FSDP, paper Table 4). Checkpoint:
6
+ ``<ckpt>/transformer/`` written by ``save_pretrained`` (sharded safetensors, config records the
7
+ widened ``in_channels=512``).
8
+ * ``lora``: LoRA adapters on the attention / fused QKV-MLP projections of every block plus the
9
+ widened ``x_embedder`` trained in full. Checkpoint: ``<ckpt>/pytorch_lora_weights.safetensors``
10
+ (diffusers LoRA format) and ``<ckpt>/x_embedder.safetensors``.
11
+
12
+ ``load_pipeline`` recognises either checkpoint type.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from pathlib import Path
17
+
18
+ import torch
19
+ from safetensors.torch import load_file, save_file
20
+
21
+ from .fill_channels import FILL_IN, widen_x_embedder
22
+
23
+ LORA_FILE = "pytorch_lora_weights.safetensors"
24
+ X_EMBEDDER_FILE = "x_embedder.safetensors"
25
+ NUM_SINGLE_BLOCKS = 24
26
+
27
+
28
+ def lora_targets() -> list[str]:
29
+ """Attention projections of the double-stream blocks, the fused QKV+MLP input projection of
30
+ the single-stream blocks, and the single-stream attention output projections."""
31
+ return (["to_k", "to_q", "to_v", "to_out.0", "to_qkv_mlp_proj"]
32
+ + [f"single_transformer_blocks.{i}.attn.to_out" for i in range(NUM_SINGLE_BLOCKS)])
33
+
34
+
35
+ def load_transformer(base: str, dtype: torch.dtype):
36
+ from diffusers import Flux2Transformer2DModel
37
+ return Flux2Transformer2DModel.from_pretrained(base, subfolder="transformer", torch_dtype=dtype)
38
+
39
+
40
+ def build_trainable(base: str, mode: str, lora_cfg=None):
41
+ """Transformer ready for training (x_embedder widened, trainable set marked).
42
+
43
+ fft: loaded in fp32 (FSDP keeps fp32 master shards and computes in bf16), all parameters
44
+ trainable. lora: base weights frozen in bf16, adapters and x_embedder trainable in fp32.
45
+ """
46
+ if mode == "fft":
47
+ tr = load_transformer(base, torch.float32)
48
+ widen_x_embedder(tr, FILL_IN)
49
+ tr.requires_grad_(True)
50
+ elif mode == "lora":
51
+ from peft import LoraConfig
52
+ tr = load_transformer(base, torch.bfloat16)
53
+ tr.requires_grad_(False)
54
+ tr.add_adapter(LoraConfig(r=int(lora_cfg.rank), lora_alpha=int(lora_cfg.alpha),
55
+ lora_dropout=float(lora_cfg.get("dropout", 0.0)),
56
+ init_lora_weights="gaussian", target_modules=lora_targets()))
57
+ widen_x_embedder(tr, FILL_IN).requires_grad_(True)
58
+ for p in tr.parameters():
59
+ if p.requires_grad:
60
+ p.data = p.data.float()
61
+ else:
62
+ raise ValueError(f"train.mode must be 'fft' or 'lora', got {mode!r}")
63
+ return tr
64
+
65
+
66
+ def save_lora(transformer, out_dir: str | Path) -> None:
67
+ """LoRA tensors in diffusers format plus the full widened x_embedder."""
68
+ from diffusers import Flux2KleinPipeline
69
+ from peft.utils import get_peft_model_state_dict
70
+ out_dir = Path(out_dir)
71
+ out_dir.mkdir(parents=True, exist_ok=True)
72
+ lora = get_peft_model_state_dict(transformer)
73
+ lora = {k: v.detach().to(torch.bfloat16).cpu().contiguous() for k, v in lora.items()}
74
+ Flux2KleinPipeline.save_lora_weights(out_dir, transformer_lora_layers=lora)
75
+ save_file({"x_embedder.weight": transformer.x_embedder.weight.detach().float().cpu().contiguous()},
76
+ str(out_dir / X_EMBEDDER_FILE))
77
+
78
+
79
+ def save_full(transformer, out_dir: str | Path, state_dict: dict | None = None) -> None:
80
+ """Full transformer as a diffusers model directory ``<out_dir>/transformer`` (bf16 shards).
81
+
82
+ ``state_dict`` is the gathered full state dict when training under FSDP (only the main
83
+ process calls this); the unwrapped module supplies the config.
84
+ """
85
+ import json
86
+ from huggingface_hub import split_torch_state_dict_into_shards
87
+ d = Path(out_dir) / "transformer"
88
+ d.mkdir(parents=True, exist_ok=True)
89
+ sd = state_dict if state_dict is not None else transformer.state_dict()
90
+ sd = {k: v.detach().to(torch.bfloat16).cpu().contiguous() for k, v in sd.items()}
91
+ split = split_torch_state_dict_into_shards(
92
+ sd, filename_pattern="diffusion_pytorch_model{suffix}.safetensors", max_shard_size="5GB")
93
+ for fname, keys in split.filename_to_tensors.items():
94
+ save_file({k: sd[k] for k in keys}, str(d / fname), metadata={"format": "pt"})
95
+ if split.is_sharded:
96
+ index = {"metadata": split.metadata, "weight_map": split.tensor_to_filename}
97
+ (d / "diffusion_pytorch_model.safetensors.index.json").write_text(json.dumps(index, indent=2))
98
+ transformer.save_config(d)
99
+
100
+
101
+ def checkpoint_kind(ckpt: str | Path) -> str:
102
+ ckpt = Path(ckpt)
103
+ if (ckpt / LORA_FILE).exists():
104
+ return "lora"
105
+ if (ckpt / "transformer" / "config.json").exists():
106
+ return "fft"
107
+ raise FileNotFoundError(f"{ckpt} holds neither {LORA_FILE} nor transformer/config.json")
108
+
109
+
110
+ def load_pipeline(base: str, ckpt: str | Path, device: str = "cuda", text_encoder: bool = True):
111
+ """``Flux2KleinPipeline`` in bf16 with the trained keyframe-translation weights.
112
+
113
+ ``text_encoder=False`` skips the text encoder (prompts encoded beforehand).
114
+
115
+ LoRA: widen ``x_embedder`` BEFORE loading the adapter (it was fitted to the 512-wide
116
+ embedding), copy the trained embedding, then load the adapter unfused.
117
+ """
118
+ from diffusers import Flux2KleinPipeline
119
+ ckpt = Path(ckpt)
120
+ kind = checkpoint_kind(ckpt)
121
+ kw = {} if text_encoder else {"text_encoder": None, "tokenizer": None}
122
+ if kind == "fft":
123
+ from diffusers import Flux2Transformer2DModel
124
+ tr = Flux2Transformer2DModel.from_pretrained(ckpt / "transformer", torch_dtype=torch.bfloat16)
125
+ pipe = Flux2KleinPipeline.from_pretrained(base, transformer=tr, torch_dtype=torch.bfloat16, **kw)
126
+ pipe.to(device)
127
+ else:
128
+ pipe = Flux2KleinPipeline.from_pretrained(base, torch_dtype=torch.bfloat16, **kw).to(device)
129
+ widen_x_embedder(pipe.transformer, FILL_IN)
130
+ w = load_file(str(ckpt / X_EMBEDDER_FILE))["x_embedder.weight"]
131
+ with torch.no_grad():
132
+ pipe.transformer.x_embedder.weight.copy_(w.to(pipe.transformer.x_embedder.weight.dtype))
133
+ pipe.load_lora_weights(str(ckpt), weight_name=LORA_FILE)
134
+ if pipe.transformer.x_embedder.in_features != FILL_IN:
135
+ raise ValueError(f"{ckpt}: x_embedder has {pipe.transformer.x_embedder.in_features} inputs")
136
+ pipe.transformer.requires_grad_(False)
137
+ return pipe
138
+
robotryon/image/sampler.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Inpainting sampler for keyframe translation (true classifier-free guidance, Euler flow matching).
2
+
3
+ ``Flux2KleinPipeline.__call__`` cannot express channel-concatenated conditioning, so the denoising
4
+ loop is written out here. The token layout must match training exactly (see ``fill_channels`` and
5
+ ``stereo_ids``)::
6
+
7
+ [ target_v (T=0, L=v): noisy | conditioned frame | mask ] for every view v
8
+ [ reference_v (T=10, L=v): 128 latent channels, zero-padded ] for every view v
9
+
10
+ The schedule is the pipeline's: ``sigmas = linspace(1, 1/steps, steps)`` shifted by the empirical
11
+ ``mu`` of the total target token count (both views for stereo). FLUX.2 [klein] Base has no guidance
12
+ embedding, so guidance is true CFG against the empty prompt:
13
+ ``v = v_uncond + s * (v_cond - v_uncond)``.
14
+
15
+ The sampler returns the final latent TOKENS. In autoregressive inference these tokens are fed
16
+ directly as the next keyframe's reference tokens, without decoding and re-encoding (App. B.2.3).
17
+ """
18
+ from __future__ import annotations
19
+
20
+ from dataclasses import dataclass
21
+
22
+ import numpy as np
23
+ import torch
24
+
25
+ from .fill_channels import BASE_IN, FILL_IN, fold_mask_to_latent, pack, pad_condition_tokens, unpack
26
+ from .stereo_ids import fill_ids
27
+
28
+
29
+ class LatentCodec:
30
+ """Pixels <-> normalised, 2x2-patchified FLUX.2 VAE latents ``(1, 128, H/16, W/16)``."""
31
+
32
+ def __init__(self, vae, device):
33
+ self.vae, self.device = vae, device
34
+ self.mean = vae.bn.running_mean.view(1, -1, 1, 1).to(device)
35
+ self.std = torch.sqrt(vae.bn.running_var.view(1, -1, 1, 1) + vae.config.batch_norm_eps).to(device)
36
+
37
+ @staticmethod
38
+ def to_tensor(img: np.ndarray) -> torch.Tensor:
39
+ """uint8 ``(H, W, 3)`` -> float ``(1, 3, H, W)`` in [-1, 1]."""
40
+ return torch.from_numpy(np.asarray(img).astype(np.float32) / 127.5 - 1).permute(2, 0, 1)[None]
41
+
42
+ @staticmethod
43
+ def patchify(lat: torch.Tensor) -> torch.Tensor:
44
+ b, c, h, w = lat.shape
45
+ lat = lat.view(b, c, h // 2, 2, w // 2, 2).permute(0, 1, 3, 5, 2, 4)
46
+ return lat.reshape(b, c * 4, h // 2, w // 2)
47
+
48
+ @staticmethod
49
+ def unpatchify(lat: torch.Tensor) -> torch.Tensor:
50
+ b, c, h, w = lat.shape
51
+ lat = lat.reshape(b, c // 4, 2, 2, h, w).permute(0, 1, 4, 2, 5, 3)
52
+ return lat.reshape(b, c // 4, h * 2, w * 2)
53
+
54
+ def encode_tensor(self, px: torch.Tensor) -> torch.Tensor:
55
+ lat = self.vae.encode(px.to(self.device, self.vae.dtype)).latent_dist.mode()
56
+ return (self.patchify(lat) - self.mean) / self.std
57
+
58
+ def encode(self, img: np.ndarray) -> torch.Tensor:
59
+ return self.encode_tensor(self.to_tensor(img))
60
+
61
+ def decode_tokens(self, tokens: torch.Tensor, h: int, w: int) -> np.ndarray:
62
+ """``(1, h*w, 128)`` tokens -> uint8 ``(16h, 16w, 3)`` image."""
63
+ lat = unpack(tokens, h, w) * self.std + self.mean
64
+ img = self.vae.decode(self.unpatchify(lat).to(self.vae.dtype), return_dict=False)[0]
65
+ return ((img[0].float().permute(1, 2, 0).clamp(-1, 1).cpu().numpy() + 1) * 127.5).astype(np.uint8)
66
+
67
+
68
+ @dataclass
69
+ class ViewCondition:
70
+ """Conditioning of one camera view.
71
+
72
+ cond_latent: ``(1, 128, h, w)`` latent of the conditioned frame Phi(o; M, S).
73
+ mask: ``(1, 1, 16h, 16w)`` pixel mask in [0, 1].
74
+ reference_tokens: ``(1, h*w, 128)`` reference tokens (an encoded image, or the previous
75
+ keyframe's denoised tokens).
76
+ """
77
+ cond_latent: torch.Tensor
78
+ mask: torch.Tensor
79
+ reference_tokens: torch.Tensor
80
+
81
+
82
+ @torch.no_grad()
83
+ def denoise(transformer, scheduler, views: list[ViewCondition], prompt_embeds, text_ids,
84
+ neg_embeds=None, neg_text_ids=None, steps: int = 30, guidance_scale: float = 3.5,
85
+ generator: torch.Generator | None = None) -> list[torch.Tensor]:
86
+ """Jointly denoise one or more views. Returns one ``(1, h*w, 128)`` token tensor per view."""
87
+ from diffusers.pipelines.flux2.pipeline_flux2_klein import compute_empirical_mu
88
+ device, dtype = prompt_embeds.device, transformer.dtype
89
+ n = len(views)
90
+ _, _, h, w = views[0].cond_latent.shape
91
+ S = h * w
92
+ cond = [torch.cat([pack(v.cond_latent.float()),
93
+ pack(fold_mask_to_latent(v.mask.to(device, torch.float32)))], dim=-1).to(dtype)
94
+ for v in views]
95
+ refs = [pad_condition_tokens(v.reference_tokens.to(dtype), FILL_IN) for v in views]
96
+ ids = fill_ids(h, w, n).to(device)
97
+ lats = [torch.randn((1, S, BASE_IN), generator=generator, device=device, dtype=dtype)
98
+ for _ in range(n)]
99
+
100
+ use_cfg = guidance_scale is not None and guidance_scale > 1.0 and neg_embeds is not None
101
+ sigmas = np.linspace(1.0, 1 / steps, steps)
102
+ scheduler.set_timesteps(sigmas=sigmas, device=device, mu=compute_empirical_mu(S * n, steps))
103
+ scheduler.set_begin_index(0)
104
+ for t in scheduler.timesteps:
105
+ hidden = torch.cat([torch.cat([lats[i], cond[i]], dim=-1) for i in range(n)] + refs, dim=1)
106
+ tt = t.expand(1).to(dtype) / 1000
107
+ pred = transformer(hidden_states=hidden, timestep=tt, guidance=None,
108
+ encoder_hidden_states=prompt_embeds, txt_ids=text_ids, img_ids=ids,
109
+ return_dict=False)[0]
110
+ if use_cfg:
111
+ neg = transformer(hidden_states=hidden, timestep=tt, guidance=None,
112
+ encoder_hidden_states=neg_embeds, txt_ids=neg_text_ids, img_ids=ids,
113
+ return_dict=False)[0]
114
+ pred = neg + guidance_scale * (pred - neg)
115
+ # One Euler step per view against the same t: rewind the scheduler's step counter.
116
+ idx = scheduler.step_index
117
+ for i in range(n):
118
+ scheduler._step_index = idx
119
+ lats[i] = scheduler.step(pred[:, i * S:(i + 1) * S], t, lats[i],
120
+ return_dict=False)[0].to(dtype)
121
+ return lats
robotryon/image/stereo_ids.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """RoPE position ids for one or two camera views in one token sequence.
2
+
3
+ FLUX.2 gives every image token a 4D position ``(T, H, W, L)``. The pipeline sets ``L = 0`` for all
4
+ image tokens (only text tokens use it), so for images ``L`` is a free axis; stereo generation
5
+ uses it as the camera-view index. ``T`` keeps its learned meaning: targets at ``T = 0`` and the
6
+ reference at ``T = 10``. The token order is::
7
+
8
+ [target_v0, target_v1, ..., reference_v0, reference_v1, ...]
9
+
10
+ With one view this is byte-identical to the stock ``_prepare_latent_ids`` + ``_prepare_image_ids``.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import torch
15
+
16
+ TARGET_T = 0
17
+ REFERENCE_T = 10
18
+
19
+
20
+ def view_image_ids(sizes: list[tuple[int, int]], view_ids: list[int],
21
+ t_coords: list[int]) -> torch.Tensor:
22
+ """``sizes[i] = (h, w)`` token grid of image i -> ``(1, sum h*w, 4)`` int64 position ids."""
23
+ if not (len(sizes) == len(view_ids) == len(t_coords)):
24
+ raise ValueError("one size, view id and t coordinate per image")
25
+ out = [torch.cartesian_prod(torch.tensor([int(t)]), torch.arange(h), torch.arange(w),
26
+ torch.tensor([int(v)]))
27
+ for (h, w), v, t in zip(sizes, view_ids, t_coords)]
28
+ return torch.cat(out, dim=0).unsqueeze(0)
29
+
30
+
31
+ def fill_ids(h: int, w: int, n_views: int) -> torch.Tensor:
32
+ """Ids for ``n_views`` targets followed by ``n_views`` references, all on an ``h x w`` grid."""
33
+ return view_image_ids([(h, w)] * (2 * n_views), list(range(n_views)) * 2,
34
+ [TARGET_T] * n_views + [REFERENCE_T] * n_views)
robotryon/image/text.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """The on-disk text-embedding cache of the image model's prompts (``robotryon.captions``).
2
+
3
+ Qwen3 prompt embeddings are ``(512, 12288)`` per prompt, too large to keep for every prompt in
4
+ memory, so they are computed once at trainer start-up and stored as one safetensors file per
5
+ prompt, named by a hash of the text.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import hashlib
10
+ from pathlib import Path
11
+
12
+ import torch
13
+ from safetensors.torch import load_file, save_file
14
+
15
+ MAX_SEQUENCE_LENGTH = 512
16
+
17
+
18
+ class TextEmbedCache:
19
+ """One ``<sha1>.safetensors`` file per prompt holding ``prompt_embeds`` ``(L, D)``."""
20
+
21
+ def __init__(self, root: str | Path):
22
+ self.root = Path(root)
23
+
24
+ def path(self, prompt: str) -> Path:
25
+ return self.root / f"{hashlib.sha1(prompt.encode('utf-8')).hexdigest()}.safetensors"
26
+
27
+ def missing(self, prompts) -> list[str]:
28
+ return sorted({p for p in prompts if not self.path(p).exists()})
29
+
30
+ def load(self, prompt: str) -> torch.Tensor:
31
+ p = self.path(prompt)
32
+ if not p.exists():
33
+ raise FileNotFoundError(f"no cached embedding for prompt {prompt!r}")
34
+ return load_file(str(p))["prompt_embeds"]
35
+
36
+ @torch.no_grad()
37
+ def fill(self, text_pipe, prompts, device, batch_size: int = 16) -> int:
38
+ """Encode and store the given prompts with a ``Flux2KleinPipeline`` holding the text encoder."""
39
+ self.root.mkdir(parents=True, exist_ok=True)
40
+ prompts = list(prompts)
41
+ for i in range(0, len(prompts), batch_size):
42
+ chunk = prompts[i:i + batch_size]
43
+ emb, _ = text_pipe.encode_prompt(prompt=chunk, device=device,
44
+ max_sequence_length=MAX_SEQUENCE_LENGTH)
45
+ for p, e in zip(chunk, emb):
46
+ tmp = self.path(p).with_suffix(".tmp")
47
+ save_file({"prompt_embeds": e.to(torch.bfloat16).contiguous().cpu()}, str(tmp))
48
+ tmp.replace(self.path(p))
49
+ return len(prompts)
50
+
51
+
52
+ def text_ids_for(prompt_embeds: torch.Tensor) -> torch.Tensor:
53
+ """``(B, L, D)`` -> ``(B, L, 4)`` text position ids (same as ``Flux2KleinPipeline._prepare_text_ids``)."""
54
+ b, n, _ = prompt_embeds.shape
55
+ ids = torch.cartesian_prod(torch.arange(1), torch.arange(1), torch.arange(1), torch.arange(n))
56
+ return ids.unsqueeze(0).expand(b, -1, -1).to(prompt_embeds.device)
robotryon/image/training.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Flow-matching objective of the keyframe-translation model (paper Eq. 3.1).
2
+
3
+ With ``z = E_img(o_t)`` and ``z_tau = (1 - tau) z + tau eps``, the transformer predicts
4
+ ``eps - z`` from the target tokens ``[z_tau | E_img(Phi(o_t; M_t, S_t)) | fold(M_t)]`` and the
5
+ reference tokens ``E_img(o_t')``. ``tau`` is drawn uniformly from the base model's training
6
+ sigma grid, one value per sample shared by its views. For stereo samples the loss is the mean
7
+ over the two views.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import torch
12
+
13
+ from .fill_channels import FILL_IN, fill_tokens, pack, pad_condition_tokens, unpack
14
+ from .sampler import LatentCodec
15
+ from .stereo_ids import fill_ids
16
+ from .text import text_ids_for
17
+
18
+
19
+ def flow_matching_loss(transformer, codec: LatentCodec, sched_sigmas: torch.Tensor,
20
+ sched_timesteps: torch.Tensor, batch: dict, device) -> torch.Tensor:
21
+ B, V = batch["target"].shape[:2]
22
+ with torch.no_grad():
23
+ enc = [{k: codec.encode_tensor(batch[k][:, v].to(device))
24
+ for k in ("target", "cond", "reference")} for v in range(V)]
25
+ z0 = enc[0]["target"]
26
+ _, _, h, w = z0.shape
27
+ n = sched_timesteps.numel()
28
+ idx = (torch.rand(B) * n).long().clamp(max=n - 1)
29
+ sigma = sched_sigmas[idx].to(device=device, dtype=z0.dtype).view(B, 1, 1, 1)
30
+ timestep = sched_timesteps[idx].to(device)
31
+
32
+ targets, tokens = [], []
33
+ for v, e in enumerate(enc):
34
+ noise = torch.randn_like(e["target"])
35
+ noisy = (1.0 - sigma) * e["target"] + sigma * noise
36
+ tokens.append(fill_tokens(noisy, e["cond"], batch["mask"][:, v].to(device)))
37
+ targets.append(noise - e["target"])
38
+ tokens += [pad_condition_tokens(pack(e["reference"]), FILL_IN) for e in enc]
39
+ hidden = torch.cat(tokens, dim=1).to(torch.bfloat16)
40
+ ids = fill_ids(h, w, V).to(device).expand(B, -1, -1)
41
+ prompt_embeds = batch["prompt_embeds"].to(device)
42
+ pred = transformer(hidden_states=hidden, timestep=timestep / 1000, guidance=None,
43
+ encoder_hidden_states=prompt_embeds, txt_ids=text_ids_for(prompt_embeds),
44
+ img_ids=ids, return_dict=False)[0]
45
+ S = h * w
46
+ losses = [((unpack(pred[:, v * S:(v + 1) * S], h, w).float() - targets[v].float()) ** 2)
47
+ .reshape(B, -1).mean(1).mean() for v in range(V)]
48
+ return sum(losses) / V
robotryon/masks/__init__.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Shape-agnostic arm/hand masks for keyframe translation (paper Sec. 3.2, App. B.2.3).
2
+
3
+ The mask must hide the silhouette of whatever embodiment it covers, so it is never the raw
4
+ segmentation: every arm component becomes its convex hull, which is then dilated. During training
5
+ the dilation is random per arm, the manipulated object is cut out of the hole with some
6
+ probability, and the whole sample is jointly rescaled (field-of-view crop / pad). At inference the
7
+ dilation is fixed and the eroded object mask is always subtracted.
8
+ """
9
+ from .hull import arm_hulls, fill_hulls, scale_hulls
10
+ from .augment import MaskAug, fov_augment, inference_mask
11
+ from .reference import robot_only
12
+
13
+ __all__ = ["arm_hulls", "fill_hulls", "scale_hulls", "MaskAug", "fov_augment", "inference_mask",
14
+ "robot_only"]
robotryon/masks/augment.py ADDED
@@ -0,0 +1,131 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Train-time mask randomisation, the fixed inference mask, and the joint field-of-view augmentation.
2
+
3
+ Training (``MaskAug.train_mask``):
4
+ * one random dilation per arm hull, so two arms in one frame already carry different
5
+ hole-to-arm ratios and the model cannot read the hand size off the hole;
6
+ * episodes flagged as "bonus" (thin human arms) get a raised dilation floor, because a hull
7
+ hugging a thin arm leaves no room for a thicker robot arm;
8
+ * with probability ``p_object_cut``, where an object mask exists, the eroded object is cut out
9
+ of the hole (a concave hole), which is what inference always does.
10
+
11
+ Inference (``inference_mask``): every hull dilated by a fixed kernel (the middle of the training
12
+ range plus the bonus), then the eroded object mask is subtracted so the object is preserved.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ from dataclasses import dataclass
17
+
18
+ import cv2
19
+ import numpy as np
20
+ from PIL import Image
21
+
22
+ from .hull import fill_hulls
23
+
24
+
25
+ def _ellipse(k: int) -> np.ndarray:
26
+ k = int(k) | 1
27
+ return cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))
28
+
29
+
30
+ @dataclass
31
+ class MaskAug:
32
+ dilate_min: int = 3
33
+ dilate_max: int = 71
34
+ bonus: int = 21 # added to the dilation floor for "bonus" (human) episodes
35
+ p_object_cut: float = 0.5
36
+ obj_erode_min: int = 3
37
+ obj_erode_max: int = 21
38
+ min_keep_frac: float = 0.4 # reject a cut that leaves less than this fraction of the hole
39
+
40
+ @classmethod
41
+ def from_config(cls, cfg) -> "MaskAug":
42
+ cfg = cfg or {}
43
+ return cls(**{k: cfg[k] for k in cls.__dataclass_fields__ if k in cfg})
44
+
45
+ def sample_dilate(self, rng: np.random.Generator, bonus: bool = False) -> int:
46
+ lo = self.dilate_min + (self.bonus if bonus else 0)
47
+ return int(rng.integers(lo, max(lo + 1, self.dilate_max) + 1)) | 1
48
+
49
+ def sample_obj_erode(self, rng: np.random.Generator) -> int:
50
+ return int(rng.integers(self.obj_erode_min, self.obj_erode_max + 1)) | 1
51
+
52
+ def infer_dilate(self) -> int:
53
+ """Fixed inference dilation: middle of the training range plus the bonus."""
54
+ return (self.dilate_min + self.dilate_max) // 2 + self.bonus
55
+
56
+ def train_mask(self, polys: list[np.ndarray], width: int, height: int,
57
+ rng: np.random.Generator, bonus: bool = False,
58
+ object_mask: np.ndarray | None = None) -> tuple[np.ndarray, int]:
59
+ """Randomised training hole. Returns ``(mask uint8 {0,1}, object erode kernel or 0)``."""
60
+ mask = np.zeros((height, width), np.uint8)
61
+ for one in fill_hulls(polys, width, height):
62
+ mask |= cv2.dilate(one, _ellipse(self.sample_dilate(rng, bonus)))
63
+ if object_mask is None:
64
+ return mask, 0
65
+ if float(rng.random()) >= self.p_object_cut:
66
+ return mask, 0
67
+ om = (np.asarray(object_mask) > 0).astype(np.uint8)
68
+ if om.sum() == 0:
69
+ return mask, 0
70
+ e = self.sample_obj_erode(rng)
71
+ om = cv2.erode(om, _ellipse(e))
72
+ cut = (mask.astype(bool) & ~om.astype(bool)).astype(np.uint8)
73
+ hull_px = int(mask.sum())
74
+ if cut.sum() > 0 and (hull_px == 0 or cut.sum() / hull_px > self.min_keep_frac):
75
+ return cut, e
76
+ return mask, 0
77
+
78
+
79
+ def inference_mask(polys: list[np.ndarray], width: int, height: int, dilate: int,
80
+ object_mask: np.ndarray | None = None, obj_erode: int = 9) -> np.ndarray:
81
+ """Fixed-dilation hull mask minus the eroded object -> ``(H, W)`` uint8 {0, 1}."""
82
+ mask = np.zeros((height, width), np.uint8)
83
+ for one in fill_hulls(polys, width, height):
84
+ mask |= one
85
+ if mask.any():
86
+ mask = cv2.dilate(mask, _ellipse(dilate))
87
+ if object_mask is not None:
88
+ om = (np.asarray(object_mask) > 0).astype(np.uint8)
89
+ if obj_erode > 0:
90
+ om = cv2.erode(om, _ellipse(obj_erode))
91
+ mask = (mask.astype(bool) & ~om.astype(bool)).astype(np.uint8)
92
+ return mask
93
+
94
+
95
+ def fov_augment(imgs: list[Image.Image], rng: np.random.Generator, crop_min: float,
96
+ scale_max: float = 1.0, flip_prob: float = 0.0) -> tuple[list[Image.Image], float]:
97
+ """One shared field-of-view draw for every stream of a sample (all views included).
98
+
99
+ ``scale ~ U[crop_min, max(1, scale_max)]``. Below 1 a ``scale``-sized window is cropped and
100
+ resized back; above 1 the image is pasted at a random offset onto a black canvas ``scale``
101
+ times larger and resized back, so the scene shrinks and black borders appear (the mask is
102
+ black there too: "not a hole"). The model thus learns that the camera's field of view is not
103
+ fixed.
104
+ """
105
+ if not imgs:
106
+ return imgs, 1.0
107
+ w, h = imgs[0].size
108
+ if any(im.size != (w, h) for im in imgs):
109
+ raise ValueError("fov_augment needs equally sized images")
110
+ scale = float(rng.uniform(crop_min, max(1.0, float(scale_max))))
111
+ if scale > 1.0 + 1e-6:
112
+ cw, ch = int(round(w * scale)), int(round(h * scale))
113
+ left = int(rng.integers(0, cw - w + 1))
114
+ top = int(rng.integers(0, ch - h + 1))
115
+ flip = float(rng.random()) < flip_prob
116
+ out = []
117
+ for im in imgs:
118
+ canvas = Image.new(im.mode, (cw, ch), 0)
119
+ canvas.paste(im, (left, top))
120
+ im2 = canvas.resize((w, h), Image.LANCZOS)
121
+ out.append(im2.transpose(Image.FLIP_LEFT_RIGHT) if flip else im2)
122
+ return out, scale
123
+ cw, ch = max(8, int(round(w * scale))), max(8, int(round(h * scale)))
124
+ left = int(rng.integers(0, w - cw + 1)) if w > cw else 0
125
+ top = int(rng.integers(0, h - ch + 1)) if h > ch else 0
126
+ flip = float(rng.random()) < flip_prob
127
+ out = []
128
+ for im in imgs:
129
+ im2 = im.crop((left, top, left + cw, top + ch)).resize((w, h), Image.LANCZOS)
130
+ out.append(im2.transpose(Image.FLIP_LEFT_RIGHT) if flip else im2)
131
+ return out, scale
robotryon/masks/hull.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Convex hull per arm.
2
+
3
+ A segmentation silhouette gives the embodiment away (a robot arm has cables and a different
4
+ outline than a human arm). Each connected component of the mask is therefore replaced by its
5
+ convex hull. Small gaps are closed before the component split, so a limb and a thin cable that
6
+ pinches to a one-pixel thread stay one component instead of producing a separate cable-shaped
7
+ polygon.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import cv2
12
+ import numpy as np
13
+
14
+ PRE_DILATE = 9 # closing kernel before the component split (px at mask resolution)
15
+ MIN_COMPONENT_PX = 256 # smaller components are segmentation noise, not an arm
16
+
17
+
18
+ def arm_hulls(mask: np.ndarray, pre_dilate: int = PRE_DILATE,
19
+ min_component_px: int = MIN_COMPONENT_PX) -> list[np.ndarray]:
20
+ """Binary mask ``(H, W)`` -> list of ``(K, 2)`` float32 hull polygons in mask pixels."""
21
+ m = (np.asarray(mask) > 0).astype(np.uint8)
22
+ if m.sum() == 0:
23
+ return []
24
+ if pre_dilate > 0:
25
+ k = pre_dilate | 1
26
+ m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k)))
27
+ n, lab = cv2.connectedComponents(m)
28
+ polys = []
29
+ for i in range(1, n):
30
+ ys, xs = np.where(lab == i)
31
+ if ys.size < min_component_px:
32
+ continue
33
+ pts = np.stack([xs, ys], axis=1).astype(np.int32)
34
+ polys.append(cv2.convexHull(pts).reshape(-1, 2).astype(np.float32))
35
+ return polys
36
+
37
+
38
+ def scale_hulls(polys: list[np.ndarray], sx: float, sy: float,
39
+ offset: tuple[float, float] = (0.0, 0.0)) -> list[np.ndarray]:
40
+ """Map polygons from mask pixels to output pixels: ``(p - offset) * (sx, sy)``."""
41
+ off = np.asarray(offset, np.float32)
42
+ s = np.array([sx, sy], np.float32)
43
+ return [(p - off) * s for p in polys]
44
+
45
+
46
+ def fill_hulls(polys: list[np.ndarray], width: int, height: int) -> list[np.ndarray]:
47
+ """Rasterise each polygon separately -> list of ``(H, W)`` uint8 {0, 1} masks."""
48
+ out = []
49
+ for p in polys:
50
+ one = np.zeros((height, width), np.uint8)
51
+ cv2.fillConvexPoly(one, p.astype(np.int32), 1)
52
+ out.append(one)
53
+ return out
robotryon/masks/reference.py ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Robot-only reference image: the robot's pixels on a plain white background.
2
+
3
+ The first keyframe of a demonstration has no translated predecessor, so its reference is a real
4
+ frame of the target robot with everything but the robot removed; this selects the embodiment.
5
+ Training uses the same construction for its robot-only exemplars.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import cv2
11
+ import numpy as np
12
+
13
+ WHITE = 255
14
+
15
+
16
+ def robot_only(image: np.ndarray, mask: np.ndarray, erode: int = 7,
17
+ background: int = WHITE) -> np.ndarray:
18
+ """Keep the robot pixels of ``image`` (H, W, 3) where ``mask`` (H, W) is set.
19
+
20
+ The mask is eroded by ``erode`` px so no background leaks in at the silhouette; everything
21
+ else is filled with ``background``.
22
+ """
23
+ m = np.asarray(mask).astype(bool)
24
+ if erode > 0:
25
+ k = int(erode) | 1
26
+ m = cv2.erode(m.astype(np.uint8), cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (k, k))) > 0
27
+ out = np.full_like(image, background)
28
+ out[m] = image[m]
29
+ return out
robotryon/skeleton/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ from .augment import SkeletonAugment
2
+ from .render import condition_frame, draw_skeleton, draw_skeleton_video, paint
3
+ from .topology import EDGES, KEYPOINT_NAMES, MEDIAPIPE21_TO_17, NUM_HANDS, NUM_KEYPOINTS
4
+
5
+ __all__ = [
6
+ "SkeletonAugment", "condition_frame", "draw_skeleton", "draw_skeleton_video", "paint",
7
+ "EDGES", "KEYPOINT_NAMES", "MEDIAPIPE21_TO_17", "NUM_HANDS", "NUM_KEYPOINTS",
8
+ ]
robotryon/skeleton/augment.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training-time skeleton augmentation.
2
+
3
+ Human keypoints at inference come from a reconstructed and retargeted hand, which is imprecise.
4
+ To make the generative models treat the skeleton as a coarse guide rather than an exact pose,
5
+ robot keypoints are corrupted during training:
6
+
7
+ * each keypoint is displaced by up to half the length of the finger segment it belongs to,
8
+ * the whole skeleton is dropped with probability ``p_drop_all``,
9
+ * each keypoint is dropped independently with probability ``p_drop_keypoint``.
10
+
11
+ For video, the displacement is sampled once per clip and per keypoint and then held fixed in
12
+ time, so the skeleton flow stays temporally coherent; keypoint dropout is also per clip.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+
19
+ import numpy as np
20
+
21
+ from .topology import segment_length_per_keypoint
22
+
23
+
24
+ @dataclass
25
+ class SkeletonAugment:
26
+ max_shift_frac: float = 0.5
27
+ p_drop_all: float = 0.2
28
+ p_drop_keypoint: float = 0.2
29
+ enabled: bool = True
30
+
31
+ @classmethod
32
+ def from_config(cls, cfg) -> "SkeletonAugment":
33
+ if cfg is None:
34
+ return cls(enabled=False)
35
+ return cls(**{k: cfg[k] for k in ("max_shift_frac", "p_drop_all", "p_drop_keypoint",
36
+ "enabled") if k in cfg})
37
+
38
+ def __call__(self, kp: np.ndarray, vis: np.ndarray, rng: np.random.Generator,
39
+ temporal: bool | None = None) -> tuple[np.ndarray, np.ndarray]:
40
+ """Corrupt keypoints.
41
+
42
+ Args:
43
+ kp: ``(H, 17, 2)`` for one frame or ``(T, H, 17, 2)`` for a clip, in pixels.
44
+ vis: matching visibility, ``(H, 17)`` or ``(T, H, 17)``.
45
+ rng: random generator.
46
+ temporal: treat the first axis as time. Inferred from ``kp.ndim == 4`` if omitted.
47
+
48
+ Returns:
49
+ ``(kp, vis)``: new arrays, the inputs are not modified.
50
+ """
51
+ kp = np.array(kp, dtype=np.float32, copy=True)
52
+ vis = np.array(vis, dtype=bool, copy=True)
53
+ if not self.enabled:
54
+ return kp, vis
55
+ if temporal is None:
56
+ temporal = kp.ndim == 4
57
+ shape = kp.shape[1:-1] if temporal else kp.shape[:-1] # (H, 17)
58
+
59
+ if rng.random() < self.p_drop_all:
60
+ vis[...] = False
61
+ return kp, vis
62
+
63
+ # Segment length from the clip median, so a single bad frame cannot blow up the shift.
64
+ seg = segment_length_per_keypoint(kp, vis)
65
+ if temporal:
66
+ seg = np.median(np.where(vis, seg, np.nan), axis=0)
67
+ seg = np.nan_to_num(seg, nan=0.0)
68
+ radius = self.max_shift_frac * seg * np.sqrt(rng.random(shape)) # uniform in a disc
69
+ angle = rng.uniform(0.0, 2 * np.pi, shape)
70
+ shift = np.stack([radius * np.cos(angle), radius * np.sin(angle)], -1).astype(np.float32)
71
+ kp = kp + (shift[None] if temporal else shift)
72
+
73
+ keep = rng.random(shape) >= self.p_drop_keypoint
74
+ vis &= keep[None] if temporal else keep
75
+ return kp, vis
robotryon/skeleton/render.py ADDED
@@ -0,0 +1,75 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Draw hand skeletons as 2D images.
2
+
3
+ The same renderer is used for every embodiment and for humans, so the skeleton carries pose
4
+ only and never reveals what kind of hand produced it.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import cv2
10
+ import numpy as np
11
+
12
+ from .topology import COLORS, EDGES, GROUP_OF
13
+
14
+
15
+ def draw_skeleton(kp: np.ndarray, vis: np.ndarray, width: int, height: int,
16
+ thickness: int = 2, radius: int = 3, supersample: int = 2) -> np.ndarray:
17
+ """Render one frame of hand skeletons on a black canvas.
18
+
19
+ Args:
20
+ kp: ``(H, 17, 2)`` pixel coordinates.
21
+ vis: ``(H, 17)`` visibility. Invisible or non-finite keypoints are skipped together with
22
+ their edges.
23
+ width, height: canvas size in pixels.
24
+
25
+ Returns:
26
+ ``(height, width, 3)`` uint8 RGB image.
27
+ """
28
+ s = max(1, int(supersample))
29
+ canvas = np.zeros((height * s, width * s, 3), np.uint8)
30
+ # Same stroke convention as the original renderer: sizes are given on the supersampled
31
+ # canvas at half scale, so thickness=2, radius=3 is the production look.
32
+ lw, dr = max(1, thickness * s // 2), max(1, radius * s // 2)
33
+ kp = np.asarray(kp, np.float64)
34
+ vis = np.asarray(vis, bool) & np.isfinite(kp).all(-1)
35
+ for h in range(kp.shape[0]):
36
+ pts = np.round(kp[h] * s).astype(np.int64) if vis[h].any() else None
37
+ if pts is None:
38
+ continue
39
+ for a, b, g in EDGES:
40
+ if vis[h, a] and vis[h, b]:
41
+ cv2.line(canvas, tuple(pts[a]), tuple(pts[b]), COLORS[g], lw, cv2.LINE_AA)
42
+ for j in range(kp.shape[1]):
43
+ if vis[h, j]:
44
+ cv2.circle(canvas, tuple(pts[j]), dr, COLORS[GROUP_OF[j]], -1, cv2.LINE_AA)
45
+ if s > 1:
46
+ canvas = cv2.resize(canvas, (width, height), interpolation=cv2.INTER_AREA)
47
+ return canvas
48
+
49
+
50
+ def draw_skeleton_video(kp: np.ndarray, vis: np.ndarray, width: int, height: int,
51
+ **kw) -> np.ndarray:
52
+ """``(T, H, 17, 2)`` keypoints -> ``(T, height, width, 3)`` uint8 skeleton video."""
53
+ return np.stack([draw_skeleton(kp[t], vis[t], width, height, **kw)
54
+ for t in range(kp.shape[0])])
55
+
56
+
57
+ def paint(frame: np.ndarray, skeleton: np.ndarray) -> np.ndarray:
58
+ """Overlay a rendered skeleton on a frame; skeleton pixels win wherever drawn."""
59
+ out = frame.copy()
60
+ lit = skeleton.max(axis=-1) > 0
61
+ out[lit] = skeleton[lit]
62
+ return out
63
+
64
+
65
+ def condition_frame(frame: np.ndarray, mask: np.ndarray | None,
66
+ skeleton: np.ndarray) -> np.ndarray:
67
+ """Phi(o; M, S) = ((1 - M) * o) (+) S: black out the mask region, then draw the skeleton.
68
+
69
+ The skeleton is drawn even slightly outside the mask so fingertips are never clipped.
70
+ ``mask=None`` leaves the frame unmasked (mask-free inference).
71
+ """
72
+ out = frame.copy()
73
+ if mask is not None:
74
+ out[np.asarray(mask).astype(bool)] = 0
75
+ return paint(out, skeleton)
robotryon/skeleton/topology.py ADDED
@@ -0,0 +1,90 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Hand keypoint topology shared by every embodiment.
2
+
3
+ Each hand has 17 keypoints in MediaPipe order with the four distal (DIP) joints removed:
4
+
5
+ 0 wrist
6
+ 1, 2, 3, 4 thumb: base (CMC), MCP, IP, tip
7
+ 5, 6, 7 index: knuckle (MCP), middle (PIP), tip
8
+ 8, 9, 10 middle
9
+ 11, 12, 13 ring
10
+ 14, 15, 16 little
11
+
12
+ Robot hands map their links onto the same 17 points, so hands with different degrees of
13
+ freedom share one skeleton. Arrays of keypoints use shape ``(..., 2, 17, D)``: hand 0 is the
14
+ left hand, hand 1 is the right hand.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import numpy as np
20
+
21
+ NUM_KEYPOINTS = 17
22
+ NUM_HANDS = 2
23
+
24
+ KEYPOINT_NAMES = (
25
+ "wrist",
26
+ "thumb_cmc", "thumb_mcp", "thumb_ip", "thumb_tip",
27
+ "index_mcp", "index_pip", "index_tip",
28
+ "middle_mcp", "middle_pip", "middle_tip",
29
+ "ring_mcp", "ring_pip", "ring_tip",
30
+ "little_mcp", "little_pip", "little_tip",
31
+ )
32
+
33
+ # MediaPipe-21 index of each of the 17 keypoints (the DIP joints 7/11/15/19 are dropped).
34
+ MEDIAPIPE21_TO_17 = (0, 1, 2, 3, 4, 5, 6, 8, 9, 10, 12, 13, 14, 16, 17, 18, 20)
35
+
36
+ GROUP_OF = (
37
+ "palm",
38
+ "thumb", "thumb", "thumb", "thumb",
39
+ "index", "index", "index",
40
+ "middle", "middle", "middle",
41
+ "ring", "ring", "ring",
42
+ "little", "little", "little",
43
+ )
44
+
45
+ # (parent, child, group). The palm connects the wrist to the thumb base and the little knuckle
46
+ # and links the knuckles to one another; each finger is a chain from its knuckle to its tip.
47
+ EDGES: tuple[tuple[int, int, str], ...] = (
48
+ (0, 1, "palm"), (0, 5, "palm"), (5, 8, "palm"), (8, 11, "palm"), (11, 14, "palm"),
49
+ (0, 14, "palm"),
50
+ (1, 2, "thumb"), (2, 3, "thumb"), (3, 4, "thumb"),
51
+ (5, 6, "index"), (6, 7, "index"),
52
+ (8, 9, "middle"), (9, 10, "middle"),
53
+ (11, 12, "ring"), (12, 13, "ring"),
54
+ (14, 15, "little"), (15, 16, "little"),
55
+ )
56
+
57
+ COLORS = {
58
+ "palm": (255, 170, 60), "thumb": (255, 232, 70), "index": (110, 235, 120),
59
+ "middle": (95, 205, 255), "ring": (185, 150, 255), "little": (255, 125, 205),
60
+ }
61
+
62
+ # Finger segments a keypoint belongs to, used to scale the training-time perturbation.
63
+ # A keypoint is displaced by at most half the length of the shortest finger segment it touches.
64
+ _FINGER_EDGES = tuple((a, b) for a, b, g in EDGES if g != "palm")
65
+
66
+
67
+ def segment_length_per_keypoint(kp: np.ndarray, vis: np.ndarray | None = None) -> np.ndarray:
68
+ """Length of the finger segment each keypoint belongs to.
69
+
70
+ Args:
71
+ kp: ``(..., 17, D)`` keypoints of one or more hands.
72
+ vis: optional ``(..., 17)`` visibility; invisible endpoints do not contribute.
73
+
74
+ Returns:
75
+ ``(..., 17)`` lengths. The wrist uses its distance to the nearest knuckle. A keypoint
76
+ without any valid segment gets 0, so it is never displaced.
77
+ """
78
+ kp = np.asarray(kp, dtype=np.float64)
79
+ if vis is None:
80
+ vis = np.isfinite(kp).all(-1)
81
+ out = np.full(kp.shape[:-1], np.inf)
82
+ edges = _FINGER_EDGES + ((0, 5), (0, 1))
83
+ for a, b in edges:
84
+ ok = vis[..., a] & vis[..., b]
85
+ d = np.linalg.norm(np.nan_to_num(kp[..., a, :] - kp[..., b, :]), axis=-1)
86
+ d = np.where(ok, d, np.inf)
87
+ out[..., a] = np.minimum(out[..., a], d)
88
+ out[..., b] = np.minimum(out[..., b], d)
89
+ out[~np.isfinite(out)] = 0.0
90
+ return out
robotryon/video/__init__.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video model g_phi: an in-context LoRA on LTX-2.5 that synthesizes the robot video between
2
+ translated keyframes under the guidance of the hand-skeleton flow (paper Sec. 3.3).
3
+
4
+ Modules:
5
+ conditioning training sequence layout, keyframe sampling, flow-matching loss
6
+ dataset random training clips with on-the-fly skeleton rendering and augmentation
7
+ rope4d fourth RoPE axis distinguishing the two stereo views
8
+ pipeline diffusers pipeline subclass and the two-stage generation of one clip
9
+ chunks splitting long videos at keyframes and concatenating the clips
10
+ text caption embeddings (cached once; the text encoder is never co-resident)
11
+ training gradient checkpointing and LoRA weight I/O
12
+ io mp4 / png helpers
13
+
14
+ Heavy dependencies (torch, diffusers) are imported by the submodules that need them.
15
+ """
robotryon/video/chunks.py ADDED
@@ -0,0 +1,164 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Splitting a long demonstration into clips the video model can generate at once.
2
+
3
+ The video VAE takes clips of ``8k + 1`` frames, and the model is trained on clips of at most
4
+ 145 frames (about 7 s at 20 fps). A longer demonstration is therefore cut at its keyframes:
5
+ each clip runs from one keyframe to a later one, holds every keyframe in between, and the next
6
+ clip starts at the keyframe the previous one ended on. Adjacent clips thus **share their
7
+ boundary keyframe**, so the arm pose cannot jump at the seam, and the concatenation drops the
8
+ duplicated boundary frame of every clip after the first.
9
+
10
+ A clip is ``8k + 1`` frames long, which usually reaches past its last keyframe; those trailing
11
+ frames are anchored to nothing and are discarded at concatenation. Two corner cases follow the
12
+ original generation code:
13
+
14
+ * two consecutive keyframes further apart than the cap: the clip is stretched past the cap
15
+ rather than leaving a hole in the video (logged as off-distribution);
16
+ * the end of the video leaves less than the minimum clip length: a short tail clip of at least
17
+ 9 frames is generated instead of dropping the end of the demonstration, pulling the last
18
+ keyframe onto the clip's final frame if the ``8k + 1`` constraint requires it.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from dataclasses import asdict, dataclass
24
+
25
+ import numpy as np
26
+
27
+ VAE_TEMPORAL = 8
28
+
29
+
30
+ def allowed_frame_counts(lo: int, hi: int) -> list[int]:
31
+ """Clip lengths in ``[lo, hi]`` the VAE accepts (``F = 8k + 1``)."""
32
+ return [f for f in range(lo, hi + 1) if (f - 1) % VAE_TEMPORAL == 0]
33
+
34
+
35
+ def next_8k1(n: int) -> int:
36
+ """Smallest ``8k + 1`` that is >= n."""
37
+ return n if (n - 1) % VAE_TEMPORAL == 0 else n + (VAE_TEMPORAL - (n - 1) % VAE_TEMPORAL)
38
+
39
+
40
+ def prev_8k1(n: int) -> int:
41
+ """Largest ``8k + 1`` that is <= n (0 if none)."""
42
+ return n - (n - 1) % VAE_TEMPORAL if n >= 1 else 0
43
+
44
+
45
+ def latent_index(pixel: int, num_frames: int) -> int:
46
+ """Latent frame holding a pixel frame: latent 0 is pixel 0, latent j>0 is [8j-7, 8j]."""
47
+ lat_n = (num_frames - 1) // VAE_TEMPORAL + 1
48
+ return 0 if pixel == 0 else min((pixel + 7) // VAE_TEMPORAL, lat_n - 1)
49
+
50
+
51
+ @dataclass
52
+ class Chunk:
53
+ """One generated clip: ``frames`` frames starting at ``start`` of the episode.
54
+
55
+ ``kf_slots`` index into the episode's keyframe list, ``kf_local`` are the same keyframes
56
+ as frame indices inside the clip (first is always 0).
57
+ """
58
+ start: int
59
+ frames: int
60
+ kf_slots: list[int]
61
+ kf_local: list[int]
62
+
63
+ def to_dict(self) -> dict:
64
+ return asdict(self)
65
+
66
+
67
+ def _tail_chunk(kf: list[int], i: int, start: int, avail: int, log) -> Chunk | None:
68
+ """A short last clip covering what is left after keyframe ``i``."""
69
+ F = prev_8k1(avail)
70
+ if F < 9:
71
+ return None
72
+ ids = [t for t in range(i, len(kf)) if kf[t] <= start + F - 1] or [i]
73
+ if ids[-1] + 1 < len(kf):
74
+ ids.append(ids[-1] + 1) # include the last keyframe, pulled to F-1
75
+ local = [min(kf[t] - start, F - 1) for t in ids]
76
+ if len(set(local)) < 2:
77
+ return None
78
+ log(f" tail clip: frames {start}..{start + F - 1} ({F}f, below the training minimum) "
79
+ f"keyframes {ids} @ {local}")
80
+ return Chunk(start, F, ids, local)
81
+
82
+
83
+ def plan_chunks(kf: list[int], total: int, frames_range=(49, 145), log=print) -> list[Chunk]:
84
+ """Cut a ``total``-frame video with keyframes ``kf`` (sorted frame indices) into clips.
85
+
86
+ Each clip starts at a keyframe and ends at a later one, holding as many keyframes as fit
87
+ in ``frames_range[1]`` frames; its length is the smallest allowed ``8k + 1`` that covers
88
+ them (at least ``frames_range[0]``).
89
+ """
90
+ choices = allowed_frame_counts(*frames_range)
91
+ lo, hi = choices[0], choices[-1]
92
+ out: list[Chunk] = []
93
+ i = 0
94
+ while i < len(kf) - 1:
95
+ j = i + 1
96
+ while j + 1 < len(kf) and (kf[j + 1] - kf[i] + 1) <= hi:
97
+ j += 1
98
+ span = kf[j] - kf[i] + 1
99
+ start = kf[i]
100
+ if span > hi:
101
+ # Two consecutive keyframes are further apart than the cap: stretch the clip.
102
+ F = next_8k1(span)
103
+ if start + F > total:
104
+ F = prev_8k1(total - start)
105
+ if F < lo:
106
+ tc = _tail_chunk(kf, i, start, total - start, log)
107
+ if tc is not None:
108
+ out.append(tc)
109
+ break
110
+ log(f" keyframes {i}->{j} are {span} frames apart, above the {hi}-frame cap: "
111
+ f"generating {F} frames")
112
+ out.append(Chunk(start, F, [i, j], [0, min(kf[j] - start, F - 1)]))
113
+ i = j
114
+ continue
115
+ avail = total - start
116
+ F = next((f for f in choices if f >= max(span, lo) and f <= avail), None)
117
+ if F is None:
118
+ # The video ends before a long-enough clip: keep the start (frame 0 of a clip is
119
+ # the substituted keyframe) and pull the last keyframe onto the clip's last frame.
120
+ F = max((f for f in choices if f <= avail), default=None)
121
+ if F is None or F < lo:
122
+ tc = _tail_chunk(kf, i, start, avail, log)
123
+ if tc is not None:
124
+ out.append(tc)
125
+ break
126
+ log(f" last clip: {span} -> {F} frames, last keyframe pulled to {F - 1}")
127
+ local = [min(k - start, F - 1) for k in kf[i:j + 1]]
128
+ out.append(Chunk(start, F, list(range(i, j + 1)), local))
129
+ i = j
130
+ return out
131
+
132
+
133
+ def drop_colliding_keyframes(kf_local: list[int], num_frames: int) -> list[int]:
134
+ """Positions (into ``kf_local``) to keep so no two keyframes share a latent frame.
135
+
136
+ Short clips can put two keyframes into the same latent slab (17 frames, keyframes at
137
+ [0, 11, 16] -> latents [0, 2, 2]), which the pipeline cannot represent. The first and last
138
+ keyframes are kept; colliding interior ones are dropped.
139
+ """
140
+ if len(kf_local) <= 2:
141
+ return list(range(len(kf_local)))
142
+ last = latent_index(kf_local[-1], num_frames)
143
+ keep, seen = [], set()
144
+ for n, loc in enumerate(kf_local):
145
+ li = latent_index(loc, num_frames)
146
+ if n < len(kf_local) - 1 and (li in seen or li == last):
147
+ continue
148
+ seen.add(li)
149
+ keep.append(n)
150
+ return keep
151
+
152
+
153
+ def concat_chunks(chunks: list[Chunk], clips: list[np.ndarray]) -> np.ndarray:
154
+ """Join generated clips ``(F, H, W, 3)`` into one video.
155
+
156
+ Clip 0 contributes frames ``0..kf_local[-1]``; every later clip starts one frame in, since
157
+ its frame 0 is the boundary keyframe the previous clip already ended on. Frames after a
158
+ clip's last keyframe are dropped.
159
+ """
160
+ parts = []
161
+ for n, (ch, clip) in enumerate(zip(chunks, clips)):
162
+ lo = ch.kf_local[0] + (1 if n else 0)
163
+ parts.append(clip[lo:ch.kf_local[-1] + 1])
164
+ return np.concatenate(parts, axis=0)
robotryon/video/conditioning.py ADDED
@@ -0,0 +1,150 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assembling one training step of the in-context video model g_phi (paper Eq. 3.3).
2
+
3
+ A training sequence holds three groups of tokens, in the order of diffusers'
4
+ ``LTX2InContextPipeline``::
5
+
6
+ [ target tokens | keyframe tokens | skeleton-flow reference tokens ]
7
+
8
+ * **target**: the clip ``w = E_vid(o_{1:T})``, noised by flow matching to ``w_tau``. The model
9
+ predicts the velocity ``eps - w`` on these tokens.
10
+ * **keyframes** ``o_T``: each keyframe is one pixel frame, VAE-encoded on its own (exactly what
11
+ inference can build from a PNG), clean (timestep 0), and placed at its true time by RoPE
12
+ coordinates ``[f, f+1) / fps``. The keyframe at frame 0 instead *replaces* latent frame 0 of
13
+ the target, which encodes exactly that one frame; it is excluded from the loss.
14
+ * **reference**: the rendered skeleton flow ``S_{1:T}``, VAE-encoded like the clip, clean, at
15
+ ``1 / ref_downscale`` resolution with its spatial coordinates scaled back onto the target
16
+ grid.
17
+
18
+ Stereo clips hold both views in every group, each view encoded separately and laid end to end
19
+ inside the group; a fourth RoPE axis (rope4d.py) tells the views apart. Mono clips use plain
20
+ three-axis coordinates. The transformer is bidirectional, so a keyframe appended at the end
21
+ of the sequence conditions every frame.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import torch
27
+
28
+ AUDIO_TOKENS = 1 # LTX-2 is audio-video; training feeds a one-token silent audio stream
29
+
30
+
31
+ def sample_keyframe_indices(num_frames: int, count_weights: dict[int, float],
32
+ include_last_p: float, generator: torch.Generator | None = None
33
+ ) -> list[int]:
34
+ """Keyframes of one training clip (paper App. B.2.4): 1 to 4, the first frame always.
35
+
36
+ The count is drawn from ``count_weights``; the remaining keyframes sit at random frames.
37
+ With probability ``include_last_p`` one of them is the clip's last frame, which is where
38
+ inference always places the shared boundary keyframe of a chunk.
39
+ """
40
+ counts = sorted(count_weights)
41
+ probs = torch.tensor([float(count_weights[c]) for c in counts])
42
+ n = min(counts[int(torch.multinomial(probs, 1, generator=generator))], num_frames)
43
+ idx = {0}
44
+ if n >= 2 and torch.rand((), generator=generator) < include_last_p:
45
+ idx.add(num_frames - 1)
46
+ pool = torch.randperm(num_frames - 1, generator=generator) + 1 # frames 1..F-1
47
+ for f in pool.tolist():
48
+ if len(idx) >= n:
49
+ break
50
+ idx.add(f)
51
+ return sorted(idx)
52
+
53
+
54
+ def _per_view(coords: torch.Tensor, num_views: int) -> list[torch.Tensor]:
55
+ if num_views == 1:
56
+ return [coords] # mono: no view axis, pretrained RoPE untouched
57
+ from .rope4d import add_view_coord
58
+ return [add_view_coord(coords, v) for v in range(num_views)]
59
+
60
+
61
+ def build_video_coords(rope, num_latent_frames: int, height: int, width: int,
62
+ ref_downscale: int, device, fps: float, keyframe_indices: list[int],
63
+ num_views: int = 1) -> torch.Tensor:
64
+ """RoPE coordinates for ``[target | keyframes | reference]``, matching ``build_step``.
65
+
66
+ ``height``/``width`` are one view's latent grid. Keyframe tokens are one pixel frame wide
67
+ at ``[f, f+1)`` (already divided by fps, like ``prepare_video_coords``).
68
+ """
69
+ tgt = rope.prepare_video_coords(1, num_latent_frames, height, width, device=device, fps=fps)
70
+ parts = _per_view(tgt, num_views)
71
+ if keyframe_indices:
72
+ one = rope.prepare_video_coords(1, 1, height, width, device=device, fps=fps)
73
+ for f in keyframe_indices:
74
+ c = one.clone()
75
+ c[:, 0, :, 0] = float(f) / fps
76
+ c[:, 0, :, 1] = float(f + 1) / fps
77
+ parts += _per_view(c, num_views)
78
+ if ref_downscale == 1:
79
+ ref = tgt.clone()
80
+ else:
81
+ ref = rope.prepare_video_coords(1, num_latent_frames, height // ref_downscale,
82
+ width // ref_downscale, device=device, fps=fps)
83
+ ref[:, 1:3] *= ref_downscale
84
+ parts += _per_view(ref, num_views)
85
+ return torch.cat(parts, dim=2)
86
+
87
+
88
+ def build_step(target: torch.Tensor, reference: torch.Tensor,
89
+ keyframes: dict[int, torch.Tensor], sigma: torch.Tensor,
90
+ tokens_per_frame: int, num_views: int = 1):
91
+ """Noise the target and lay out one sequence.
92
+
93
+ Args:
94
+ target: ``(1, V * seq, C)`` packed clean target tokens, view-major.
95
+ reference: ``(1, V * ref_seq, C)`` packed skeleton-flow tokens.
96
+ keyframes: ``{pixel frame: (1, V * tokens_per_frame, C)}``, one-frame encodes.
97
+ sigma: ``(1,)`` flow-matching noise level in [0, 1].
98
+ tokens_per_frame: spatial tokens of one view's latent frame.
99
+
100
+ Returns:
101
+ ``(hidden, timestep, velocity, pinned, appended)``: the input tokens, per-token
102
+ timesteps (``sigma * 1000`` on noised tokens, 0 on clean ones), the velocity target
103
+ for the first ``V * seq`` outputs, a mask of target tokens replaced by the frame-0
104
+ keyframe, and the appended keyframe indices in sequence order.
105
+ """
106
+ b, seq, _ = target.shape
107
+ per_view = seq // num_views
108
+ noise = torch.randn_like(target)
109
+ s = sigma.view(-1, 1, 1).to(target.dtype)
110
+ noisy = (1.0 - s) * target + s * noise
111
+ pinned = torch.zeros(b, seq, dtype=torch.bool, device=target.device)
112
+ if 0 in keyframes: # frame 0 replaces latent frame 0 of each view
113
+ noisy = noisy.clone()
114
+ for v in range(num_views):
115
+ dst, src = v * per_view, v * tokens_per_frame
116
+ noisy[:, dst:dst + tokens_per_frame] = keyframes[0][:, src:src + tokens_per_frame]
117
+ pinned[:, dst:dst + tokens_per_frame] = True
118
+ appended = sorted(f for f in keyframes if f != 0)
119
+ hidden = torch.cat([noisy] + [keyframes[f] for f in appended] + [reference], dim=1)
120
+ ts = (sigma.view(-1, 1) * 1000.0).to(target.dtype).expand(b, hidden.shape[1]).clone()
121
+ ts[:, :seq] = torch.where(pinned, torch.zeros_like(ts[:, :seq]), ts[:, :seq])
122
+ ts[:, seq:] = 0.0
123
+ return hidden, ts, noise - target, pinned, appended
124
+
125
+
126
+ def flow_matching_loss(pred: torch.Tensor, velocity: torch.Tensor,
127
+ pinned: torch.Tensor) -> torch.Tensor:
128
+ """MSE between predicted and true velocity over the noised target tokens."""
129
+ keep = (~pinned).unsqueeze(-1)
130
+ diff = (pred.float() - velocity.float()) ** 2 * keep
131
+ return diff.sum() / keep.expand_as(diff).sum().clamp(min=1)
132
+
133
+
134
+ def shifted_logit_normal_sigma(seq_len: int, device=None, std: float = 1.0, eps: float = 1e-3,
135
+ uniform_prob: float = 0.1) -> torch.Tensor:
136
+ """Noise level for one sample, as LTX-2's trainer draws it.
137
+
138
+ Adapted from LTX-2 ``ltx_trainer.timestep_samplers.ShiftedLogitNormalTimestepSampler``
139
+ (Apache-2.0): a logit-normal whose mean shifts with the token count, stretched between its
140
+ 0.5th and 99.9th percentiles, mixed with 10% uniform samples.
141
+ """
142
+ m = (2.05 - 0.95) / (4096 - 1024)
143
+ mu = m * seq_len + (0.95 - m * 1024)
144
+ x = torch.sigmoid(torch.randn((1,), device=device) * std + mu)
145
+ p999 = torch.sigmoid(torch.tensor(mu + 3.0902 * std, device=device))
146
+ p005 = torch.sigmoid(torch.tensor(mu - 2.5758 * std, device=device))
147
+ z = (x - p005) / (p999 - p005)
148
+ z = torch.clamp(torch.where(z >= eps, z, 2 * eps - z), 0, 1)
149
+ uniform = (1 - eps) * torch.rand((1,), device=device) + eps
150
+ return torch.where(torch.rand((1,), device=device) > uniform_prob, z, uniform)
robotryon/video/dataset.py ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Training clips for the video model: robot video, its skeleton flow, and its caption.
2
+
3
+ Episodes follow the shared format (``<root>/<episode>/meta.json``, ``video_<view>.mp4``,
4
+ ``keypoints_<view>.npz``). One item is a random window of one episode:
5
+
6
+ * length ``F`` drawn uniformly from the ``8k + 1`` values in ``frames_range`` that fit,
7
+ start drawn uniformly;
8
+ * stereo episodes give both views (``V = 2``), except with probability ``mono_p`` where one
9
+ random view is used (``V = 1``), so one model learns stereo and mono generation;
10
+ * one crop rectangle (bottom-anchored, scale in ``[min_scale, 1]``) and one horizontal flip
11
+ for every view and for the skeleton. A mirrored stereo pair also swaps its views, since the
12
+ reflected right image is what a left camera would see;
13
+ * the skeleton flow ``S_{1:T}`` is rendered on the fly from the keypoints, after the paper's
14
+ augmentation (``robotryon.skeleton.SkeletonAugment``: shift every keypoint by up to half its
15
+ finger segment, drop the whole skeleton with p=0.2, each keypoint with p=0.2), held fixed
16
+ over the clip. The views of a stereo clip are augmented jointly (the hands of both views
17
+ share one drop-all decision).
18
+
19
+ The skeleton is drawn directly at ``skeleton_size`` (the reference resolution), the same
20
+ resolution inference renders at.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import json
26
+ import random
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+
30
+ import cv2
31
+ import numpy as np
32
+ import torch
33
+ from torch.utils.data import Dataset
34
+
35
+ from ..skeleton import SkeletonAugment, draw_skeleton_video
36
+ from .chunks import allowed_frame_counts
37
+ from .io import read_frames
38
+
39
+
40
+ @dataclass
41
+ class Episode:
42
+ path: Path
43
+ views: tuple[str, ...]
44
+ num_frames: int
45
+ caption: str
46
+ root_index: int
47
+
48
+
49
+ def caption_text(template: str, meta: dict, embodiment: str | None = None) -> str:
50
+ """The text prompt: ``template`` filled with the episode's caption (and embodiment)."""
51
+ return template.format(caption=meta.get("caption", ""),
52
+ embodiment=embodiment or meta.get("embodiment", ""))
53
+
54
+
55
+ def load_episodes(roots: list, min_frames: int, fps: float,
56
+ caption_template: str = "{caption}") -> tuple[list[Episode], list[float]]:
57
+ """All usable episodes under ``roots`` and their sampling weights.
58
+
59
+ ``roots`` items are paths or ``{path, weight}``. A root's weight is split over its
60
+ episodes in proportion to their length.
61
+ """
62
+ episodes, weights = [], []
63
+ for ri, r in enumerate(roots):
64
+ path, w = (r, 1.0) if isinstance(r, str) else (r["path"], float(r.get("weight", 1.0)))
65
+ found = []
66
+ for m in sorted(Path(path).glob("*/meta.json")):
67
+ meta = json.loads(m.read_text())
68
+ if abs(float(meta["fps"]) - fps) > 1e-6:
69
+ raise ValueError(f"{m}: fps {meta['fps']} != {fps}; resample the episode first")
70
+ if int(meta["num_frames"]) < min_frames:
71
+ continue
72
+ found.append(Episode(m.parent, tuple(meta["views"]), int(meta["num_frames"]),
73
+ caption_text(caption_template, meta), ri))
74
+ if not found:
75
+ raise FileNotFoundError(f"no episode with >= {min_frames} frames under {path}")
76
+ total = sum(e.num_frames for e in found)
77
+ episodes += found
78
+ weights += [w * e.num_frames / total for e in found]
79
+ return episodes, weights
80
+
81
+
82
+ def crop_rect(W: int, H: int, aspect: float, scale: float, rng: random.Random):
83
+ """A crop of the given aspect, bottom-anchored (arms enter from the bottom edge)."""
84
+ bw, bh = (int(round(H * aspect)), H) if W / H > aspect else (W, int(round(W / aspect)))
85
+ w = max(8, int(round(bw * scale)))
86
+ h = min(bh, max(8, int(round(w / aspect))))
87
+ return rng.randint(0, max(0, W - w)), H - h, w, h
88
+
89
+
90
+ def crop_frames(frames: np.ndarray, rect, flip: bool, size) -> np.ndarray:
91
+ x, y, w, h = rect
92
+ out = np.empty((len(frames), size[1], size[0], 3), np.uint8)
93
+ for i, f in enumerate(frames):
94
+ c = f[y:y + h, x:x + w]
95
+ out[i] = cv2.resize(c[:, ::-1] if flip else c, size, interpolation=cv2.INTER_AREA)
96
+ return out
97
+
98
+
99
+ def crop_keypoints(kp: np.ndarray, rect, flip: bool, size) -> np.ndarray:
100
+ """Map pixel keypoints through the same crop, flip and resize as the frames."""
101
+ x, y, w, h = rect
102
+ out = np.array(kp, np.float32, copy=True)
103
+ out[..., 0] = (out[..., 0] - x + 0.5) * size[0] / w - 0.5
104
+ out[..., 1] = (out[..., 1] - y + 0.5) * size[1] / h - 0.5
105
+ if flip:
106
+ out[..., 0] = size[0] - 1 - out[..., 0]
107
+ return out
108
+
109
+
110
+ def to_tensor(a: np.ndarray) -> torch.Tensor:
111
+ """``(V, F, H, W, 3)`` uint8 RGB -> ``(V, 3, F, H, W)`` float in [-1, 1]."""
112
+ return torch.from_numpy(np.ascontiguousarray(a)).permute(0, 4, 1, 2, 3).float() / 127.5 - 1
113
+
114
+
115
+ class VideoClipDataset(Dataset):
116
+ """Random training windows. Items are unbatched dicts (clip lengths differ)."""
117
+
118
+ def __init__(self, episodes: list[Episode], out_size, skeleton_size, frames_range,
119
+ min_scale: float, flip_p: float, mono_p: float, augment: SkeletonAugment,
120
+ draw_kw: dict | None = None, fps: float = 20.0, seed: int = 0):
121
+ self.episodes = episodes
122
+ self.out_size, self.skeleton_size = tuple(out_size), tuple(skeleton_size)
123
+ self.choices = allowed_frame_counts(*frames_range)
124
+ self.min_scale, self.flip_p, self.mono_p = min_scale, flip_p, mono_p
125
+ self.augment, self.draw_kw = augment, dict(draw_kw or {})
126
+ self.fps, self.seed, self.epoch = fps, seed, 0
127
+
128
+ def set_epoch(self, epoch: int) -> None:
129
+ self.epoch = epoch
130
+
131
+ def __len__(self) -> int:
132
+ return len(self.episodes)
133
+
134
+ def __getitem__(self, idx: int) -> dict:
135
+ ep = self.episodes[idx]
136
+ rng = random.Random(hash((self.seed, self.epoch, idx)) & 0xFFFFFFFF)
137
+ nrng = np.random.default_rng(rng.getrandbits(64))
138
+ F = rng.choice([f for f in self.choices if f <= ep.num_frames])
139
+ start = rng.randint(0, ep.num_frames - F)
140
+ views = list(ep.views)
141
+ if len(views) > 1 and rng.random() < self.mono_p:
142
+ views = [rng.choice(views)]
143
+
144
+ frames = [read_frames(ep.path / f"video_{v}.mp4", start, F) for v in views]
145
+ kps, vis = [], []
146
+ for v in views:
147
+ z = np.load(ep.path / f"keypoints_{v}.npz")
148
+ kps.append(z["kp"][start:start + F])
149
+ vis.append(z["vis"][start:start + F].astype(bool))
150
+
151
+ H, W = frames[0].shape[1:3]
152
+ rect = crop_rect(W, H, self.out_size[0] / self.out_size[1],
153
+ rng.uniform(self.min_scale, 1.0), rng)
154
+ flip = rng.random() < self.flip_p
155
+ video = np.stack([crop_frames(f, rect, flip, self.out_size) for f in frames])
156
+ kp = np.concatenate([crop_keypoints(k, rect, flip, self.skeleton_size) for k in kps], 1)
157
+ kp, vs = self.augment(kp, np.concatenate(vis, 1), nrng, temporal=True)
158
+ sw, sh = self.skeleton_size
159
+ skel = np.stack([draw_skeleton_video(kp[:, 2 * i:2 * i + 2], vs[:, 2 * i:2 * i + 2],
160
+ sw, sh, **self.draw_kw)
161
+ for i in range(len(views))])
162
+ if flip and len(views) == 2: # a mirrored stereo pair swaps cameras
163
+ video, skel = video[::-1], skel[::-1]
164
+ return {"video": to_tensor(video), "condition": to_tensor(skel),
165
+ "caption": ep.caption, "num_frames": F, "fps": self.fps,
166
+ "meta": {"episode": ep.path.name, "start": start, "views": len(views),
167
+ "flip": flip}}
robotryon/video/io.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Minimal mp4 / png I/O for the video model (RGB uint8 arrays, OpenCV underneath)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import cv2
8
+ import numpy as np
9
+
10
+
11
+ def read_frames(path: Path, start: int, count: int, size=None) -> np.ndarray:
12
+ """Frames ``[start, start + count)`` as ``(count, H, W, 3)`` RGB, optionally resized
13
+ (INTER_AREA) to ``size = (W, H)``."""
14
+ cap = cv2.VideoCapture(str(path))
15
+ cap.set(cv2.CAP_PROP_POS_FRAMES, start)
16
+ frames = []
17
+ for _ in range(count):
18
+ ok, f = cap.read()
19
+ if not ok:
20
+ break
21
+ if size is not None:
22
+ f = cv2.resize(f, tuple(size), interpolation=cv2.INTER_AREA)
23
+ frames.append(f[..., ::-1])
24
+ cap.release()
25
+ if len(frames) < count:
26
+ raise RuntimeError(f"{path}: wanted {count} frames from {start}, got {len(frames)}")
27
+ return np.ascontiguousarray(np.stack(frames))
28
+
29
+
30
+ def num_frames(path: Path) -> int:
31
+ cap = cv2.VideoCapture(str(path))
32
+ n = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
33
+ cap.release()
34
+ return n
35
+
36
+
37
+ def read_image(path: Path) -> np.ndarray:
38
+ """PNG -> ``(H, W, 3)`` RGB uint8 at its own resolution."""
39
+ img = cv2.imread(str(path))
40
+ if img is None:
41
+ raise FileNotFoundError(path)
42
+ return np.ascontiguousarray(img[..., ::-1])
43
+
44
+
45
+ def write_video(frames: np.ndarray, path: Path, fps: float) -> None:
46
+ """``(F, H, W, 3)`` RGB uint8 -> mp4 (mp4v)."""
47
+ path.parent.mkdir(parents=True, exist_ok=True)
48
+ h, w = frames.shape[1:3]
49
+ wr = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
50
+ for f in frames:
51
+ wr.write(np.ascontiguousarray(f[..., ::-1]))
52
+ wr.release()
robotryon/video/pipeline.py ADDED
@@ -0,0 +1,271 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video synthesis with the in-context LoRA (paper Eq. 3.4 and App. B.4).
2
+
3
+ ``StereoIcLoraPipeline`` extends diffusers' ``LTX2InContextPipeline`` with two things:
4
+
5
+ * **stereo**: ``conditions`` and ``reference_conditions`` are per-view lists. Each view is
6
+ prepared by the parent and the token groups are interleaved by role,
7
+ ``[base(L) base(R) | kf(L) kf(R) | ref(L) ref(R)]``, so the reference tokens stay at the end
8
+ of the sequence where the parent's attention mask expects them. A fourth RoPE axis
9
+ (rope4d.py) tells the views apart; mono uses the pipeline's own coordinates.
10
+ * **exact keyframe times**: diffusers places a keyframe at a latent index, which snaps it to
11
+ pixel frame ``8k - 7``. Training places keyframes at their exact pixel frame ``[f, f+1)``,
12
+ so the appended keyframe coordinates are rewritten to the requested pixel frames. The
13
+ keyframe at frame 0 replaces latent frame 0 and needs no rewrite.
14
+
15
+ ``generate`` runs the two-stage recipe on one clip. Both stages use the full LTX-2.5
16
+ transformer with the IC-LoRA and Lightricks' distilled LoRA, the published distilled sigma
17
+ schedules (8 steps, then 3 refinement steps from sigma 0.909) and neutral guidance. Both stages
18
+ are conditioned on the same keyframes and skeleton flow. ``two_stage.mode``:
19
+
20
+ * ``same_res``: stage 1 already at the output resolution; stage 2 refines at that resolution.
21
+ * ``upsample``: stage 1 at half the output resolution, the stage-1 latents are upsampled x2 by
22
+ the LTX-2.5 spatial latent upsampler, and stage 2 refines at the output resolution.
23
+
24
+ The skeleton flow is always given at one ``skeleton_size`` (512x288); each stage encodes it
25
+ with ``reference_downscale = stage width / skeleton width`` (1 at 512x288, 2 at 1024x576),
26
+ exactly as the two training stages did.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import glob
32
+ import os
33
+
34
+ import numpy as np
35
+ import torch
36
+ from diffusers.pipelines.ltx2 import LTX2InContextPipeline
37
+
38
+ from .chunks import latent_index
39
+ from .rope4d import add_view_coord, install_view_axis_rope, set_view_coord
40
+
41
+ MAX_SEQ = 256 # text tokens; training used the same
42
+ DISTILLED_LORA = "ltx-2.5-22b-distilled-lora-450-bf16.safetensors"
43
+
44
+
45
+ def snapshot_dir(repo_or_path: str) -> str:
46
+ """A local directory for a Hugging Face repo id (newest cached snapshot) or a path."""
47
+ if os.path.isdir(repo_or_path):
48
+ return repo_or_path
49
+ from huggingface_hub import snapshot_download
50
+ from huggingface_hub.constants import HF_HUB_CACHE
51
+ slug = "models--" + repo_or_path.replace("/", "--")
52
+ hits = sorted(glob.glob(os.path.join(HF_HUB_CACHE, slug, "snapshots", "*")))
53
+ return hits[-1] if hits else snapshot_download(repo_or_path)
54
+
55
+
56
+ class StereoIcLoraPipeline(LTX2InContextPipeline):
57
+ """``LTX2InContextPipeline`` with a view axis and exact-frame keyframes."""
58
+
59
+ num_views = 1
60
+ exact_kf_px: list[int] | None = None # pixel frames of the appended keyframes
61
+
62
+ def _unpack_latents(self, latents, num_frames, height, width, patch_size=1,
63
+ patch_size_t=1):
64
+ """Unpack the views' base tokens into a batch ``(V, C, F, H, W)`` for decoding."""
65
+ per_view = num_frames * height * width
66
+ views = latents.shape[1] // per_view
67
+ if views < 2:
68
+ return super()._unpack_latents(latents, num_frames, height, width, patch_size,
69
+ patch_size_t)
70
+ return torch.cat([super(StereoIcLoraPipeline, self)._unpack_latents(
71
+ latents[:, v * per_view:(v + 1) * per_view], num_frames, height, width,
72
+ patch_size, patch_size_t) for v in range(views)], dim=0)
73
+
74
+ def _retime_keyframes(self, kf_coords, n_kf, kw):
75
+ """Move the appended keyframe blocks to their exact pixel frames ``[f, f+1) / fps``."""
76
+ if not self.exact_kf_px or not n_kf:
77
+ return kf_coords
78
+ fps = float(kw["frame_rate"])
79
+ tpf = (int(kw["height"]) // 32) * (int(kw["width"]) // 32)
80
+ if n_kf != len(self.exact_kf_px) * tpf:
81
+ raise ValueError(f"{n_kf} appended keyframe tokens do not match "
82
+ f"{len(self.exact_kf_px)} keyframes of {tpf} tokens")
83
+ out = kf_coords.clone()
84
+ for j, f in enumerate(self.exact_kf_px):
85
+ sl = slice(j * tpf, (j + 1) * tpf)
86
+ out[:, 0, sl, 0] = float(f) / fps
87
+ out[:, 0, sl, 1] = float(f + 1) / fps
88
+ return out
89
+
90
+ def prepare_latents(self, conditions=None, reference_conditions=None, **kw):
91
+ views = self.num_views
92
+ conditions = conditions or [None] * views
93
+ reference_conditions = reference_conditions or [None] * views
94
+ lat_in = kw.pop("latents", None) # stage 2: stage-1 (upsampled) latents per view
95
+
96
+ def view_latents(v):
97
+ if lat_in is None:
98
+ return None
99
+ if lat_in.dim() == 5 and lat_in.shape[0] == views:
100
+ return lat_in[v:v + 1]
101
+ raise ValueError(f"latents {tuple(lat_in.shape)} do not split into {views} views")
102
+
103
+ self._single_view_coords = True # reference coords from the parent stay 3-axis
104
+ try:
105
+ per = [super(StereoIcLoraPipeline, self).prepare_latents(
106
+ conditions=conditions[v], reference_conditions=reference_conditions[v],
107
+ latents=view_latents(v), **kw) for v in range(views)]
108
+ finally:
109
+ self._single_view_coords = False
110
+
111
+ groups = {k: [[] for _ in range(3)] for k in ("lat", "mask", "clean")}
112
+ kf_co, ref_co, ref_masks, ref_counts = [], [], [], []
113
+ for v, (lat, mask, clean, acoords, nref, rmask, rcounts) in enumerate(per):
114
+ n_app = 0 if acoords is None else acoords.shape[2]
115
+ n_kf, n_base = n_app - nref, lat.shape[1] - n_app
116
+ bounds = (slice(0, n_base), slice(n_base, n_base + n_kf),
117
+ slice(n_base + n_kf, lat.shape[1]))
118
+ for name, src in (("lat", lat), ("mask", mask), ("clean", clean)):
119
+ for g, sl in enumerate(bounds):
120
+ groups[name][g].append(src[:, sl])
121
+ if acoords is not None:
122
+ a4 = set_view_coord(acoords, v) if views > 1 else acoords
123
+ kf_co.append(self._retime_keyframes(a4[:, :, :n_kf], n_kf, kw))
124
+ ref_co.append(a4[:, :, n_kf:])
125
+ if rmask is not None:
126
+ ref_masks.append(rmask)
127
+ ref_counts.extend(rcounts or [])
128
+
129
+ def flat(g):
130
+ return torch.cat(g[0] + g[1] + g[2], dim=1)
131
+ coords = torch.cat(kf_co + ref_co, dim=2) if (kf_co or ref_co) else None
132
+ return (flat(groups["lat"]), flat(groups["mask"]), flat(groups["clean"]), coords,
133
+ sum(x.shape[1] for x in groups["lat"][2]),
134
+ torch.cat(ref_masks, dim=1) if ref_masks else None, ref_counts)
135
+
136
+
137
+ def _install_stereo(pipe: StereoIcLoraPipeline) -> None:
138
+ """View-axis RoPE, and a base grid that covers every view (view axis stamped per view)."""
139
+ install_view_axis_rope(pipe.transformer)
140
+ pipe._single_view_coords = False
141
+ rope = pipe.transformer.rope
142
+ orig = rope.prepare_video_coords
143
+
144
+ def widened(*args, **kwargs):
145
+ plain = orig(*args, **kwargs)
146
+ if pipe._single_view_coords:
147
+ return plain
148
+ return torch.cat([add_view_coord(plain, v) for v in range(pipe.num_views)], dim=2)
149
+
150
+ rope.prepare_video_coords = widened
151
+
152
+
153
+ def build_pipeline(lora: str, *, base: str = "Lightricks/LTX-2.5-Diffusers",
154
+ transformer_subfolder: str = "transformer_full", views: int = 1,
155
+ attention_backend: str | None = "native", upsample: bool = False,
156
+ device: str = "cuda") -> StereoIcLoraPipeline:
157
+ """Load LTX-2.5 + IC-LoRA + distilled LoRA, without the text encoder.
158
+
159
+ Args:
160
+ lora: checkpoint directory holding ``pytorch_lora_weights.safetensors`` (or the file).
161
+ views: views generated jointly in one sequence (2 = stereo, 1 = mono).
162
+ upsample: also load the spatial latent upsampler (``two_stage.mode: upsample``).
163
+ """
164
+ from diffusers import LTX2VideoTransformer3DModel
165
+
166
+ root = snapshot_dir(base)
167
+ pipe = StereoIcLoraPipeline.from_pretrained(
168
+ root, transformer=LTX2VideoTransformer3DModel.from_pretrained(
169
+ root, subfolder=transformer_subfolder, dtype=torch.bfloat16),
170
+ text_encoder=None, tokenizer=None, dtype=torch.bfloat16)
171
+ pipe.num_views = int(views)
172
+ if views > 1:
173
+ _install_stereo(pipe)
174
+ lora_dir, name = ((os.path.dirname(lora), os.path.basename(lora)) if os.path.isfile(lora)
175
+ else (lora, "pytorch_lora_weights.safetensors"))
176
+ pipe.load_lora_weights(lora_dir, weight_name=name, adapter_name="iclora")
177
+ if not any("lora" in k for k, _ in pipe.transformer.named_parameters()):
178
+ raise RuntimeError(f"no LoRA weights attached from {lora}")
179
+ pipe.load_lora_weights(root, weight_name=DISTILLED_LORA, adapter_name="distilled")
180
+ pipe.set_adapters(["iclora", "distilled"], [1.0, 1.0])
181
+ if attention_backend:
182
+ pipe.transformer.set_attention_backend(attention_backend)
183
+ pipe.vae.enable_tiling()
184
+ pipe.vae.enable_slicing()
185
+ pipe.to(device)
186
+ pipe.upsampler = None
187
+ if upsample:
188
+ from diffusers.pipelines.ltx2 import LTX2LatentUpsamplePipeline
189
+ from diffusers.pipelines.ltx2.latent_upsampler import LTX2LatentUpsamplerModel
190
+ model = LTX2LatentUpsamplerModel.from_pretrained(
191
+ root, subfolder="latent_upsampler", dtype=torch.bfloat16).to(device)
192
+ pipe.upsampler = LTX2LatentUpsamplePipeline(vae=pipe.vae, latent_upsampler=model)
193
+ return pipe
194
+
195
+
196
+ def _u8(img: torch.Tensor) -> np.ndarray:
197
+ """``(3, H, W)`` in [-1, 1] -> ``(1, H, W, 3)`` uint8 (keyframe convention)."""
198
+ return ((img.clamp(-1, 1) + 1) * 127.5).byte().permute(1, 2, 0)[None].cpu().numpy()
199
+
200
+
201
+ def _f01(video: torch.Tensor) -> np.ndarray:
202
+ """``(3, F, H, W)`` in [-1, 1] -> ``(F, H, W, 3)`` float in [0, 1] (reference convention)."""
203
+ return ((video.clamp(-1, 1) + 1) / 2).permute(1, 2, 3, 0).float().cpu().numpy()
204
+
205
+
206
+ def _to_uint8(out: torch.Tensor, views: int) -> torch.Tensor:
207
+ """Pipeline ``pt`` output in [0, 1], ``(V, F, 3, H, W)`` -> ``(V, F, H, W, 3)`` uint8."""
208
+ v = out if out.shape[0] == views else out[0]
209
+ frames = [x if x.shape[0] == 3 else x.permute(1, 0, 2, 3) for x in v] # -> (3, F, H, W)
210
+ return torch.stack([(x.float().clamp(0, 1) * 255).byte().permute(1, 2, 3, 0).cpu()
211
+ for x in frames])
212
+
213
+
214
+ @torch.no_grad()
215
+ def generate(pipe: StereoIcLoraPipeline, *, skeleton: torch.Tensor,
216
+ keyframes: list[tuple[int, torch.Tensor]], text: tuple, num_frames: int,
217
+ size: tuple[int, int], fps: float, seed: int = 0,
218
+ mode: str = "same_res") -> torch.Tensor:
219
+ """Synthesize one clip.
220
+
221
+ Args:
222
+ skeleton: ``(V, 3, F, h, w)`` skeleton flow in [-1, 1] at the reference resolution.
223
+ keyframes: ``[(pixel frame, (V, 3, H, W) in [-1, 1])]`` sorted, first at frame 0. Any
224
+ resolution: the pipeline resizes them for each stage.
225
+ text: ``(prompt_embeds, mask, negative_embeds, negative_mask)`` (see text.py).
226
+ size: output ``(width, height)`` of one view.
227
+ mode: ``same_res`` or ``upsample``.
228
+
229
+ Returns:
230
+ ``(V, F, height, width, 3)`` uint8 RGB.
231
+ """
232
+ from diffusers.pipelines.ltx2 import LTX2ReferenceCondition, LTX2VideoCondition
233
+ from diffusers.pipelines.ltx2.utils import (DISTILLED_SIGMA_VALUES,
234
+ STAGE_2_DISTILLED_SIGMA_VALUES)
235
+ if mode not in ("same_res", "upsample"):
236
+ raise ValueError(f"two_stage.mode must be same_res or upsample, got {mode!r}")
237
+ views = int(skeleton.shape[0])
238
+ if views != pipe.num_views:
239
+ raise ValueError(f"{views} views given to a {pipe.num_views}-view pipeline")
240
+ px = [p for p, _ in keyframes]
241
+ lat = [latent_index(p, num_frames) for p in px]
242
+ if px != sorted(set(px)) or len(set(lat)) != len(lat):
243
+ raise ValueError(f"keyframes must be sorted and in distinct latent frames: {px}")
244
+ pipe.exact_kf_px = [p for p, li in zip(px, lat) if li != 0]
245
+
246
+ W, H = size
247
+ skel_w = skeleton.shape[-1]
248
+ W1, H1 = (W // 2, H // 2) if mode == "upsample" else (W, H)
249
+ pe, pm, npe, npm = (x.cuda() for x in text)
250
+ kw = dict(prompt_embeds=pe, prompt_attention_mask=pm, negative_prompt_embeds=npe,
251
+ negative_prompt_attention_mask=npm,
252
+ conditions=[[LTX2VideoCondition(frames=_u8(img[v]), index=li, crf=0)
253
+ for li, (_, img) in zip(lat, keyframes)] for v in range(views)],
254
+ reference_conditions=[[LTX2ReferenceCondition(frames=_f01(skeleton[v]))]
255
+ for v in range(views)],
256
+ reference_downscale_factor=W1 // skel_w, max_sequence_length=MAX_SEQ,
257
+ height=H1, width=W1, num_frames=num_frames, frame_rate=fps,
258
+ generator=torch.Generator("cuda").manual_seed(seed), return_dict=False,
259
+ audio_latents=torch.zeros(1, pipe.audio_latent_channels, 1,
260
+ pipe.audio_mel_bins // pipe.audio_vae_mel_compression_ratio,
261
+ device="cuda", dtype=torch.float32),
262
+ guidance_scale=1.0, stg_scale=0.0, modality_scale=1.0, audio_guidance_scale=1.0,
263
+ audio_stg_scale=0.0, audio_modality_scale=1.0)
264
+
265
+ lat1 = pipe(sigmas=list(DISTILLED_SIGMA_VALUES), output_type="latent", **kw)[0]
266
+ if mode == "upsample":
267
+ lat1 = pipe.upsampler(latents=lat1, output_type="latent", return_dict=False)[0]
268
+ s2 = list(STAGE_2_DISTILLED_SIGMA_VALUES)
269
+ kw.update(height=H, width=W, reference_downscale_factor=W // skel_w)
270
+ out = pipe(sigmas=s2, latents=lat1, noise_scale=s2[0], output_type="pt", **kw)[0]
271
+ return _to_uint8(out, views)
robotryon/video/rope4d.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """A fourth RoPE axis for the camera view.
2
+
3
+ Both stereo views are generated in one sequence: each view is encoded by the VAE on its own
4
+ and the token runs are concatenated. To let attention relate the same (t, h, w) position in the
5
+ two cameras, every token also carries a view coordinate.
6
+
7
+ Passing four axes to the stock LTX-2 rotary embedding would re-cut its channel-to-frequency
8
+ map (``dim // (2 * num_axes)`` frequencies per axis) and perturb every pretrained head at once.
9
+ Instead the t/h/w frequencies stay exactly where they were, and the view rotation is written
10
+ into the last ``VIEW_CHANNELS_PER_HEAD`` channels of every head's slice (64 of 2048 channels),
11
+ using the lowest frequency of the table. With two views the coordinates sit a quarter turn
12
+ apart. Three-axis coordinates fall through to the original implementation unchanged, so mono
13
+ clips see the pretrained embedding exactly.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import types
19
+
20
+ import torch
21
+
22
+ VIEW_CHANNELS_PER_HEAD = 2
23
+ BASE_VIEWS = 2
24
+
25
+
26
+ def view_channel_indices(dim: int, num_heads: int, per_head: int, device=None) -> torch.Tensor:
27
+ """Channels taken by the view axis: the last ``per_head`` of each head's slice, head-major."""
28
+ head_half = (dim // 2) // num_heads
29
+ if per_head >= head_half:
30
+ raise ValueError(f"per_head={per_head} would take a head's whole {head_half} channels")
31
+ return torch.tensor([h * head_half + head_half - per_head + c
32
+ for h in range(num_heads) for c in range(per_head)],
33
+ dtype=torch.long, device=device)
34
+
35
+
36
+ def _forward_with_view_axis(self, coords: torch.Tensor, device=None):
37
+ """Drop-in for ``LTX2AudioVideoRotaryPosEmbed.forward`` accepting a 4th (view) axis."""
38
+ device = device or coords.device
39
+ if coords.shape[1] != 4:
40
+ return self._forward_3axis(coords, device)
41
+ if self.rope_type != "split":
42
+ raise NotImplementedError(f"view axis needs rope_type='split', got {self.rope_type!r}")
43
+ if coords.ndim == 4: # [start, end) -> midpoints
44
+ start, end = coords.chunk(2, dim=-1)
45
+ coords = ((start + end) / 2.0).squeeze(-1)
46
+ spatial, view = coords[:, :3], coords[:, 3]
47
+
48
+ # t/h/w exactly as the original: three axes, dim // 6 frequencies each
49
+ max_positions = (self.base_num_frames, self.base_height, self.base_width)
50
+ grid = torch.stack([spatial[:, i] / max_positions[i] for i in range(3)], dim=-1).to(device)
51
+ freqs_dtype = torch.float64 if self.double_precision else torch.float32
52
+ pow_indices = torch.pow(self.theta, torch.linspace(0.0, 1.0, steps=self.dim // 6,
53
+ dtype=freqs_dtype, device=device))
54
+ freqs_base = (pow_indices * torch.pi / 2.0).to(dtype=torch.float32)
55
+ angles = ((grid.unsqueeze(-1) * 2 - 1) * freqs_base).transpose(-1, -2).flatten(2)
56
+
57
+ # pad the angle (cos 1 / sin 0 in the original) to dim // 2
58
+ pad = self.dim // 2 - angles.shape[-1]
59
+ if pad < 0:
60
+ raise RuntimeError(f"angle width {angles.shape[-1]} exceeds dim//2")
61
+ if pad > 0:
62
+ angles = torch.cat([torch.zeros_like(angles[:, :, :pad]), angles], dim=-1)
63
+
64
+ # the view rotation in the tail channels of every head
65
+ idx = view_channel_indices(self.dim, self.num_attention_heads, VIEW_CHANNELS_PER_HEAD,
66
+ device=device)
67
+ view_angle = ((view.to(device).unsqueeze(-1) / BASE_VIEWS * 2 - 1)
68
+ * freqs_base[:VIEW_CHANNELS_PER_HEAD])
69
+ angles = angles.clone()
70
+ angles[:, :, idx] = view_angle.repeat(1, 1, self.num_attention_heads).to(angles.dtype)
71
+
72
+ b, n = angles.shape[:2]
73
+ cos = angles.cos().reshape(b, n, self.num_attention_heads, -1).swapaxes(1, 2)
74
+ sin = angles.sin().reshape(b, n, self.num_attention_heads, -1).swapaxes(1, 2)
75
+ return cos, sin
76
+
77
+
78
+ def install_view_axis_rope(transformer_or_rope) -> None:
79
+ """Patch the video rope (only) to accept view coordinates. Idempotent."""
80
+ rope = getattr(transformer_or_rope, "rope", transformer_or_rope)
81
+ if getattr(rope, "_view_axis_installed", False):
82
+ return
83
+ rope._forward_3axis = types.MethodType(type(rope).forward, rope)
84
+ rope.forward = types.MethodType(_forward_with_view_axis, rope)
85
+ rope._view_axis_installed = True
86
+
87
+
88
+ def add_view_coord(coords: torch.Tensor, view: int) -> torch.Tensor:
89
+ """``(B, 3, N, 2)`` -> ``(B, 4, N, 2)`` with the view axis constant at ``[view, view+1)``."""
90
+ b, _, n, _ = coords.shape
91
+ v = torch.empty(b, 1, n, 2, dtype=coords.dtype, device=coords.device)
92
+ v[..., 0] = float(view)
93
+ v[..., 1] = float(view + 1)
94
+ return torch.cat([coords, v], dim=1)
95
+
96
+
97
+ def set_view_coord(coords: torch.Tensor, view: int) -> torch.Tensor:
98
+ """Set the view axis, appending it if the coordinates have only three axes."""
99
+ if coords.shape[1] != 4:
100
+ return add_view_coord(coords, view)
101
+ out = coords.clone()
102
+ out[:, 3, :, 0] = float(view)
103
+ out[:, 3, :, 1] = float(view + 1)
104
+ return out
105
+
106
+
107
+ @torch.no_grad()
108
+ def check_bands_preserved(rope, device=None, atol: float = 1e-3) -> dict:
109
+ """Verify the patched rope changes only the view channels (run before training).
110
+
111
+ Channels count as changed when they differ by more than ``atol``: the 3-axis and 4-axis
112
+ paths are computed separately and can differ by float rounding on some CPUs."""
113
+ device = device or torch.device("cpu")
114
+ base = rope.prepare_video_coords(1, 4, 9, 16, device=device, fps=20.0)
115
+ cos3, sin3 = rope._forward_3axis(base, device)
116
+ cos4, sin4 = rope(add_view_coord(base, 0))
117
+ idx = view_channel_indices(rope.dim, rope.num_attention_heads, VIEW_CHANNELS_PER_HEAD,
118
+ device=cos3.device)
119
+ keep = torch.ones(rope.dim // 2, dtype=torch.bool, device=cos3.device)
120
+ keep[idx] = False
121
+ keep = keep.view(rope.num_attention_heads, -1)[None, :, None, :]
122
+ d_cos, d_sin = (cos4 - cos3).abs(), (sin4 - sin3).abs()
123
+ changed = ((d_cos > atol) | (d_sin > atol)).any(dim=2).squeeze(0)
124
+ return {"n_changed": int(changed.sum()),
125
+ "n_expected": rope.num_attention_heads * VIEW_CHANNELS_PER_HEAD,
126
+ "heads_touched": int(changed.any(dim=-1).sum()),
127
+ "num_heads": rope.num_attention_heads,
128
+ "max_abs_diff_off_view": float(torch.max((d_cos * keep).max(),
129
+ (d_sin * keep).max()))}